From 902055d5eaa7f5512b76e22f57df3939f956181f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 1 Apr 2026 22:03:14 -0500 Subject: Add tracker management (delete/update, ACLs, labels) - Add swipe actions (delete/edit) to tracker list - Add tracker settings menu on detail screen - Implement updateTracker and deleteTracker mutations - Implement ACL management (updateUserACL, updateTrackerACL, deleteACL) - Implement label management (createLabel, updateLabel, deleteLabel) - Add dedicated UI flows for ACLs and labels - Ensure state refresh after mutations - Handle loading, errors, and destructive confirmations Implements: https://todo.sr.ht/~ccleberg/Hutch/12 --- Hutch.xcodeproj/project.pbxproj | 16 +- Hutch/Models/Todo.swift | 49 + Hutch/Views/Tickets/TicketDetailViewModel.swift | 124 +-- Hutch/Views/Tickets/TicketListView.swift | 142 ++- Hutch/Views/Tickets/TicketListViewModel.swift | 60 +- Hutch/Views/Tickets/TrackerListView.swift | 199 ++-- Hutch/Views/Tickets/TrackerListViewModel.swift | 112 ++ Hutch/Views/Tickets/TrackerManagementView.swift | 1268 ++++++++++++++++++++++ HutchTests/TrackerManagementViewModelTests.swift | 36 + 9 files changed, 1783 insertions(+), 223 deletions(-) create mode 100644 Hutch/Views/Tickets/TrackerManagementView.swift create mode 100644 HutchTests/TrackerManagementViewModelTests.swift diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index d844cd3..bf79a48 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -495,7 +495,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -512,7 +512,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.1; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -532,7 +532,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -549,7 +549,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6.1; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -612,7 +612,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -622,7 +622,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.1; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -641,7 +641,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 16; + CURRENT_PROJECT_VERSION = 17; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -651,7 +651,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.6.1; + MARKETING_VERSION = 2.7.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift index 8a2b06c..129d4bf 100644 --- a/Hutch/Models/Todo.swift +++ b/Hutch/Models/Todo.swift @@ -81,6 +81,55 @@ struct TicketLabel: Codable, Sendable, Identifiable, Hashable { let foregroundColor: String } +// MARK: - Tracker ACL + +struct TrackerACLPermissions: Codable, Sendable, Hashable { + let browse: Bool + let submit: Bool + let comment: Bool + let edit: Bool + let triage: Bool +} + +struct TrackerACL: Codable, Sendable, Identifiable, Hashable { + let id: Int + let created: Date + let entity: Entity + let browse: Bool + let submit: Bool + let comment: Bool + let edit: Bool + let triage: Bool + + var permissions: TrackerACLPermissions { + TrackerACLPermissions( + browse: browse, + submit: submit, + comment: comment, + edit: edit, + triage: triage + ) + } +} + +struct DefaultTrackerACL: Codable, Sendable, Hashable { + let browse: Bool + let submit: Bool + let comment: Bool + let edit: Bool + let triage: Bool + + var permissions: TrackerACLPermissions { + TrackerACLPermissions( + browse: browse, + submit: submit, + comment: comment, + edit: edit, + triage: triage + ) + } +} + // MARK: - Tracker /// A bug tracker from todo.sr.ht. diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index 142c62b..999374b 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -3,10 +3,6 @@ import Foundation // MARK: - Response types (file-private to avoid @MainActor Decodable issues) private struct TicketDetailResponse: Decodable, Sendable { - let user: UserTrackerTicketWrapper -} - -private struct UserTrackerTicketWrapper: Decodable, Sendable { let tracker: TrackerTicketWrapper } @@ -85,10 +81,6 @@ private struct CreateLabelResponse: Decodable, Sendable { } private struct TrackerLabelsResponse: Decodable, Sendable { - let user: UserTrackerLabelsWrapper -} - -private struct UserTrackerLabelsWrapper: Decodable, Sendable { let tracker: TrackerLabelsWrapper } @@ -155,57 +147,55 @@ final class TicketDetailViewModel { // MARK: - Queries private static let detailQuery = """ - query ticket($owner: String!, $tracker: String!, $ticketId: Int!) { - user(username: $owner) { - tracker(name: $tracker) { - ticket(id: $ticketId) { - id - created - updated - title: subject - description: body - status - resolution - authenticity - submitter { canonicalName } - assignees { canonicalName } - labels { id name backgroundColor foregroundColor } - events { - results { - id - created - changes { - eventType - ... on Comment { - author { canonicalName } - text - authenticity - } - ... on StatusChange { - oldStatus - newStatus - } - ... on LabelUpdate { - labeler { canonicalName } - label { name } - } - ... on Assignment { - assigner { canonicalName } - assignee { canonicalName } - } - ... on TicketMention { - mentioned { id } - } - ... on UserMention { - mentioned { canonicalName } - } - ... on Created { - author { canonicalName } - } + query ticket($rid: ID!, $ticketId: Int!) { + tracker(rid: $rid) { + ticket(id: $ticketId) { + id + created + updated + title: subject + description: body + status + resolution + authenticity + submitter { canonicalName } + assignees { canonicalName } + labels { id name backgroundColor foregroundColor } + events { + results { + id + created + changes { + eventType + ... on Comment { + author { canonicalName } + text + authenticity + } + ... on StatusChange { + oldStatus + newStatus + } + ... on LabelUpdate { + labeler { canonicalName } + label { name } + } + ... on Assignment { + assigner { canonicalName } + assignee { canonicalName } + } + ... on TicketMention { + mentioned { id } + } + ... on UserMention { + mentioned { canonicalName } + } + ... on Created { + author { canonicalName } } } - cursor } + cursor } } } @@ -268,12 +258,10 @@ final class TicketDetailViewModel { """ private static let trackerLabelsQuery = """ - query trackerLabels($owner: String!, $tracker: String!) { - user(username: $owner) { - tracker(name: $tracker) { - labels { - results { id name backgroundColor foregroundColor } - } + query trackerLabels($rid: ID!) { + tracker(rid: $rid) { + labels { + results { id name backgroundColor foregroundColor } } } } @@ -302,13 +290,12 @@ final class TicketDetailViewModel { service: .todo, query: Self.detailQuery, variables: [ - "owner": ownerUsername, - "tracker": trackerName, + "rid": trackerRid, "ticketId": ticketId ], responseType: TicketDetailResponse.self ) - let payload = result.user.tracker.ticket + let payload = result.tracker.ticket ticket = TicketDetail( id: payload.id, created: payload.created, @@ -579,13 +566,10 @@ final class TicketDetailViewModel { let result = try await client.execute( service: .todo, query: Self.trackerLabelsQuery, - variables: [ - "owner": ownerUsername, - "tracker": trackerName - ], + variables: ["rid": trackerRid], responseType: TrackerLabelsResponse.self ) - trackerLabels = result.user.tracker.labels.results + trackerLabels = result.tracker.labels.results } catch { self.error = error.userFacingMessage } diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index 69ab419..baed21c 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -1,17 +1,32 @@ import SwiftUI struct TicketListView: View { - let ownerUsername: String - let trackerName: String - let trackerId: Int - let trackerRid: String + let onTrackerUpdated: (TrackerSummary) -> Void + let onTrackerDeleted: (TrackerSummary) -> Void @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var tracker: TrackerSummary @State private var viewModel: TicketListViewModel? + @State private var trackerManagementViewModel: TrackerManagementViewModel? @State private var showCreateTicketSheet = false @State private var createdTicket: TicketSummary? @State private var labelEditorTicket: LabelEditorTicket? + @State private var showTrackerEditor = false + @State private var showTrackerACLs = false + @State private var showTrackerLabels = false + @State private var showDeleteTrackerConfirmation = false + + init( + tracker: TrackerSummary, + onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in }, + onTrackerDeleted: @escaping (TrackerSummary) -> Void = { _ in } + ) { + self._tracker = State(initialValue: tracker) + self.onTrackerUpdated = onTrackerUpdated + self.onTrackerDeleted = onTrackerDeleted + } var body: some View { Group { @@ -21,11 +36,17 @@ struct TicketListView: View { SRHTLoadingStateView(message: "Loading tickets…") } } - .navigationTitle(trackerName) + .navigationTitle(tracker.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { - SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) { + SRHTShareButton( + url: SRHTWebURL.tracker( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name + ), + target: .tracker + ) { Image(systemName: "square.and.arrow.up") } @@ -36,6 +57,8 @@ struct TicketListView: View { Image(systemName: "plus") } .accessibilityLabel("Create ticket") + + trackerActionsMenu } } } @@ -56,6 +79,57 @@ struct TicketListView: View { .presentationDetents([.medium]) } } + .sheet(isPresented: $showTrackerEditor) { + if let trackerManagementViewModel { + TrackerEditorSheet( + title: "Update Tracker", + confirmationTitle: "Save", + isSaving: trackerManagementViewModel.isSavingTracker, + error: trackerManagementViewModel.error, + initialName: tracker.name, + initialDescription: tracker.description ?? "", + initialVisibility: tracker.visibility + ) { name, description, visibility in + if let updatedTracker = await trackerManagementViewModel.updateTracker( + name: name, + description: description, + visibility: visibility + ) { + tracker = updatedTracker + onTrackerUpdated(updatedTracker) + return true + } + return false + } + } + } + .sheet(isPresented: $showTrackerACLs) { + if let trackerManagementViewModel { + TrackerACLManagementSheet(viewModel: trackerManagementViewModel) + .presentationDetents([.large]) + } + } + .sheet(isPresented: $showTrackerLabels) { + if let trackerManagementViewModel { + TrackerLabelManagementSheet(viewModel: trackerManagementViewModel) + .presentationDetents([.large]) + } + } + .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + guard let trackerManagementViewModel else { return } + Task { + let didDelete = await trackerManagementViewModel.deleteTracker() + if didDelete { + onTrackerDeleted(tracker) + dismiss() + } + } + } + } message: { + Text("“\(tracker.name)” will be permanently deleted.") + } .navigationDestination(isPresented: Binding( get: { createdTicket != nil }, set: { isPresented in @@ -65,19 +139,26 @@ struct TicketListView: View { } )) { if let createdTicket { - TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id) + TicketDetailView( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid, + ticketId: createdTicket.id + ) } } .task { if viewModel == nil { let vm = TicketListViewModel( - ownerUsername: ownerUsername, - trackerName: trackerName, - trackerId: trackerId, - trackerRid: trackerRid, + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid, client: appState.client ) viewModel = vm + trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) await vm.loadTickets() } } @@ -163,8 +244,45 @@ struct TicketListView: View { await viewModel.loadTickets() } .navigationDestination(for: TicketSummary.self) { ticket in - TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id) + TicketDetailView( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid, + ticketId: ticket.id + ) + } + } + + private var trackerActionsMenu: some View { + Menu { + Button { + showTrackerEditor = true + } label: { + Label("Update Tracker", systemImage: "pencil") + } + + Button { + showTrackerACLs = true + } label: { + Label("Manage ACLs", systemImage: "person.2") + } + + Button { + showTrackerLabels = true + } label: { + Label("Manage Labels", systemImage: "tag") + } + + Button(role: .destructive) { + showDeleteTrackerConfirmation = true + } label: { + Label("Delete Tracker", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") } + .accessibilityLabel("Tracker actions") } @ViewBuilder diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift index 4a9f8df..9f2fe79 100644 --- a/Hutch/Views/Tickets/TicketListViewModel.swift +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -3,10 +3,6 @@ import Foundation // MARK: - Response types (file-private to avoid @MainActor Decodable issues) private struct TrackerTicketsResponse: Decodable, Sendable { - let user: UserTrackerWrapper -} - -private struct UserTrackerWrapper: Decodable, Sendable { let tracker: TrackerTicketsWrapper } @@ -38,10 +34,6 @@ private struct LabelMutationResponse: Decodable, Sendable { } private struct TrackerLabelsResponse: Decodable, Sendable { - let user: UserTrackerLabelsWrapper -} - -private struct UserTrackerLabelsWrapper: Decodable, Sendable { let tracker: TrackerLabelsWrapper } @@ -116,22 +108,20 @@ final class TicketListViewModel { // MARK: - Query private static let query = """ - query tickets($owner: String!, $tracker: String!, $cursor: Cursor) { - user(username: $owner) { - tracker(name: $tracker) { - tickets(cursor: $cursor) { - results { - id - title: subject - status - resolution - created - submitter { canonicalName } - labels { id name backgroundColor foregroundColor } - assignees { canonicalName } - } - cursor + query tickets($rid: ID!, $cursor: Cursor) { + tracker(rid: $rid) { + tickets(cursor: $cursor) { + results { + id + title: subject + status + resolution + created + submitter { canonicalName } + labels { id name backgroundColor foregroundColor } + assignees { canonicalName } } + cursor } } } @@ -185,12 +175,10 @@ final class TicketListViewModel { """ private static let trackerLabelsQuery = """ - query trackerLabels($owner: String!, $tracker: String!) { - user(username: $owner) { - tracker(name: $tracker) { - labels { - results { id name backgroundColor foregroundColor } - } + query trackerLabels($rid: ID!) { + tracker(rid: $rid) { + labels { + results { id name backgroundColor foregroundColor } } } } @@ -404,13 +392,10 @@ final class TicketListViewModel { let result = try await client.execute( service: .todo, query: Self.trackerLabelsQuery, - variables: [ - "owner": ownerUsername, - "tracker": trackerName - ], + variables: ["rid": trackerRid], responseType: TrackerLabelsResponse.self ) - trackerLabels = result.user.tracker.labels.results + trackerLabels = result.tracker.labels.results } catch { self.error = error.userFacingMessage } @@ -530,10 +515,7 @@ final class TicketListViewModel { } private func fetchPage(cursor: String?) async throws -> TicketsPage { - var variables: [String: any Sendable] = [ - "owner": ownerUsername, - "tracker": trackerName - ] + var variables: [String: any Sendable] = ["rid": trackerRid] if let cursor { variables["cursor"] = cursor } @@ -543,7 +525,7 @@ final class TicketListViewModel { variables: variables, responseType: TrackerTicketsResponse.self ) - return result.user.tracker.tickets + return result.tracker.tickets } private struct SubmitTicketResponse: Decodable, Sendable { diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift index a324eae..ac539df 100644 --- a/Hutch/Views/Tickets/TrackerListView.swift +++ b/Hutch/Views/Tickets/TrackerListView.swift @@ -5,6 +5,8 @@ struct TrackerListView: View { @State private var viewModel: TrackerListViewModel? @State private var showCreateTrackerSheet = false @State private var createdTracker: TrackerSummary? + @State private var editingTracker: TrackerSummary? + @State private var pendingDeletion: TrackerSummary? var body: some View { Group { @@ -28,9 +30,50 @@ struct TrackerListView: View { } .sheet(isPresented: $showCreateTrackerSheet) { if let viewModel { - CreateTrackerSheet(viewModel: viewModel) { tracker in - showCreateTrackerSheet = false - createdTracker = tracker + TrackerEditorSheet( + title: "New Tracker", + confirmationTitle: "Create Tracker", + isSaving: viewModel.isCreatingTracker, + error: viewModel.error, + initialName: "", + initialDescription: "", + initialVisibility: .public + ) { name, description, visibility in + if let tracker = await viewModel.createTracker( + name: name, + description: description, + visibility: visibility + ) { + createdTracker = tracker + showCreateTrackerSheet = false + return true + } + return false + } + } + } + .sheet(item: $editingTracker) { tracker in + if let viewModel { + TrackerEditorSheet( + title: "Update Tracker", + confirmationTitle: "Save", + isSaving: viewModel.isCreatingTracker, + error: viewModel.error, + initialName: tracker.name, + initialDescription: tracker.description ?? "", + initialVisibility: tracker.visibility + ) { name, description, visibility in + if let updatedTracker = await viewModel.updateTracker( + tracker, + name: name, + description: description, + visibility: visibility + ) { + createdTracker = createdTracker?.id == tracker.id ? updatedTracker : createdTracker + editingTracker = nil + return true + } + return false } } } @@ -43,12 +86,42 @@ struct TrackerListView: View { } )) { if let createdTracker { - TicketListView( - ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()), - trackerName: createdTracker.name, - trackerId: createdTracker.id, - trackerRid: createdTracker.rid - ) + TicketListView(tracker: createdTracker) { updatedTracker in + viewModel?.applyTrackerUpdate(updatedTracker) + self.createdTracker = updatedTracker + } onTrackerDeleted: { deletedTracker in + viewModel?.applyTrackerDeletion(deletedTracker) + if let viewModel { + Task { await viewModel.loadTrackers() } + } + self.createdTracker = nil + } + } + } + .alert("Delete Tracker?", isPresented: Binding( + get: { pendingDeletion != nil }, + set: { isPresented in + if !isPresented { + pendingDeletion = nil + } + } + )) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + guard let pendingDeletion, let viewModel else { return } + Task { + let didDelete = await viewModel.deleteTracker(pendingDeletion) + if didDelete { + if createdTracker?.id == pendingDeletion.id { + createdTracker = nil + } + self.pendingDeletion = nil + } + } + } + } message: { + if let pendingDeletion { + Text("“\(pendingDeletion.name)” will be permanently deleted.") } } .task { @@ -69,6 +142,20 @@ struct TrackerListView: View { NavigationLink(value: tracker) { TrackerRowView(tracker: tracker) } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + pendingDeletion = tracker + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + editingTracker = tracker + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } .task { await viewModel.loadMoreIfNeeded(currentItem: tracker) } @@ -116,92 +203,16 @@ struct TrackerListView: View { await viewModel.loadTrackers() } .navigationDestination(for: TrackerSummary.self) { tracker in - TicketListView( - ownerUsername: String(tracker.owner.canonicalName.dropFirst()), - trackerName: tracker.name, - trackerId: tracker.id, - trackerRid: tracker.rid - ) - } - } -} - -private struct CreateTrackerSheet: View { - let viewModel: TrackerListViewModel - let onCreated: (TrackerSummary) -> Void - - @Environment(\.dismiss) private var dismiss - @Bindable var viewModelBindable: TrackerListViewModel - @State private var name = "" - @State private var description = "" - @State private var visibility: Visibility = .public - - init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) { - self.viewModel = viewModel - self._viewModelBindable = Bindable(viewModel) - self.onCreated = onCreated - } - - var body: some View { - NavigationStack { - Form { - Section("Tracker Details") { - TextField("Tracker name", text: $name) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField("Short description (optional)", text: $description, axis: .vertical) - .lineLimit(2...4) - Picker("Visibility", selection: $visibility) { - Text("Public").tag(Visibility.public) - Text("Unlisted").tag(Visibility.unlisted) - Text("Private").tag(Visibility.private) - } + TicketListView(tracker: tracker) { updatedTracker in + viewModel.applyTrackerUpdate(updatedTracker) + if createdTracker?.id == updatedTracker.id { + createdTracker = updatedTracker } - - if let error = viewModel.error { - Section { - Label { - Text(error) - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - } - .foregroundStyle(.red) - } - } - } - .navigationTitle("New Tracker") - .navigationBarTitleDisplayMode(.inline) - .onDisappear { - viewModelBindable.error = nil - } - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - viewModelBindable.error = nil - dismiss() - } - } - ToolbarItem(placement: .confirmationAction) { - Button { - Task { - if let tracker = await viewModel.createTracker( - name: name, - description: description, - visibility: visibility - ) { - onCreated(tracker) - } - } - } label: { - if viewModel.isCreatingTracker { - ProgressView() - .controlSize(.small) - } else { - Text("Create Tracker") - } - } - .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker) + } onTrackerDeleted: { deletedTracker in + viewModel.applyTrackerDeletion(deletedTracker) + Task { await viewModel.loadTrackers() } + if createdTracker?.id == deletedTracker.id { + createdTracker = nil } } } diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift index 31c07d7..f18bb7b 100644 --- a/Hutch/Views/Tickets/TrackerListViewModel.swift +++ b/Hutch/Views/Tickets/TrackerListViewModel.swift @@ -11,6 +11,18 @@ private struct TrackersPage: Decodable, Sendable { let cursor: String? } +private struct UpdateTrackerResponse: Decodable, Sendable { + let updateTracker: TrackerSummary +} + +private struct DeleteTrackerResponse: Decodable, Sendable { + let deleteTracker: DeletedTracker +} + +private struct DeletedTracker: Decodable, Sendable { + let id: Int +} + // MARK: - View Model @Observable @@ -75,6 +87,28 @@ final class TrackerListViewModel { } """ + private static let updateTrackerMutation = """ + mutation updateTracker($id: Int!, $input: TrackerInput!) { + updateTracker(id: $id, input: $input) { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + } + """ + + private static let deleteTrackerMutation = """ + mutation deleteTracker($id: Int!) { + deleteTracker(id: $id) { + id + } + } + """ + // MARK: - Public API func loadTrackers() async { @@ -155,6 +189,84 @@ final class TrackerListViewModel { } } + func updateTracker( + _ tracker: TrackerSummary, + name: String, + description: String, + visibility: Visibility + ) async -> TrackerSummary? { + guard !isCreatingTracker else { return nil } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + error = "Enter a tracker name." + return nil + } + + isCreatingTracker = true + error = nil + defer { isCreatingTracker = false } + + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + let input: [String: any Sendable] = [ + "name": trimmedName, + "description": trimmedDescription.isEmpty ? "" : trimmedDescription, + "visibility": visibility.rawValue + ] + + do { + let result = try await client.execute( + service: .todo, + query: Self.updateTrackerMutation, + variables: [ + "id": tracker.id, + "input": input + ], + responseType: UpdateTrackerResponse.self + ) + applyTrackerUpdate(result.updateTracker) + return result.updateTracker + } catch { + self.error = "Couldn’t update the tracker. \(error.userFacingMessage)" + return nil + } + } + + func deleteTracker(_ tracker: TrackerSummary) async -> Bool { + guard !isCreatingTracker else { return false } + + isCreatingTracker = true + error = nil + defer { isCreatingTracker = false } + + do { + _ = try await client.execute( + service: .todo, + query: Self.deleteTrackerMutation, + variables: ["id": tracker.id], + responseType: DeleteTrackerResponse.self + ) + trackers.removeAll { $0.id == tracker.id } + await loadTrackers() + return true + } catch { + self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)" + return false + } + } + + func applyTrackerUpdate(_ tracker: TrackerSummary) { + if let index = trackers.firstIndex(where: { $0.id == tracker.id }) { + trackers[index] = tracker + } else { + trackers.insert(tracker, at: 0) + } + } + + func applyTrackerDeletion(_ tracker: TrackerSummary) { + trackers.removeAll { $0.id == tracker.id } + } + // MARK: - Private private func fetchPage(cursor: String?) async throws -> TrackersPage { diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift new file mode 100644 index 0000000..f07c878 --- /dev/null +++ b/Hutch/Views/Tickets/TrackerManagementView.swift @@ -0,0 +1,1268 @@ +import SwiftUI + +private struct TrackerQueryResponse: Decodable, Sendable { + let tracker: TrackerSummary? +} + +private struct TrackerACLQueryResponse: Decodable, Sendable { + let tracker: TrackerACLQueryPayload? +} + +private struct TrackerACLQueryPayload: Decodable, Sendable { + let defaultACL: DefaultTrackerACL + let acls: TrackerACLPage +} + +private struct TrackerACLPage: Decodable, Sendable { + let results: [TrackerACL] + let cursor: String? +} + +private struct TrackerLabelQueryResponse: Decodable, Sendable { + let tracker: TrackerLabelQueryPayload? +} + +private struct TrackerLabelQueryPayload: Decodable, Sendable { + let labels: TrackerLabelPage +} + +private struct TrackerLabelPage: Decodable, Sendable { + let results: [TicketLabel] + let cursor: String? +} + +private struct UpdateTrackerResponse: Decodable, Sendable { + let updateTracker: TrackerSummary +} + +private struct DeleteTrackerResponse: Decodable, Sendable { + let deleteTracker: DeletedTracker +} + +private struct DeletedTracker: Decodable, Sendable { + let id: Int +} + +private struct UpdateUserACLResponse: Decodable, Sendable { + let updateUserACL: TrackerACL +} + +private struct UpdateTrackerACLResponse: Decodable, Sendable { + let updateTrackerACL: DefaultTrackerACL +} + +private struct DeleteTrackerACLResponse: Decodable, Sendable { + let deleteACL: TrackerACL +} + +private struct CreateTrackerLabelResponse: Decodable, Sendable { + let createLabel: TicketLabel +} + +private struct UpdateTrackerLabelResponse: Decodable, Sendable { + let updateLabel: TicketLabel +} + +private struct DeleteTrackerLabelResponse: Decodable, Sendable { + let deleteLabel: TicketLabel +} + +private struct TrackerUserLookupResponse: Decodable, Sendable { + let user: UserIdPayload? +} + +private struct UserIdPayload: Decodable, Sendable { + let id: Int +} + +@Observable +@MainActor +final class TrackerManagementViewModel { + private(set) var tracker: TrackerSummary + private(set) var acls: [TrackerACL] = [] + private(set) var defaultACL = DefaultTrackerACL( + browse: true, + submit: true, + comment: true, + edit: false, + triage: false + ) + private(set) var labels: [TicketLabel] = [] + + private(set) var isSavingTracker = false + private(set) var isDeletingTracker = false + private(set) var isLoadingACLs = false + private(set) var isSavingACL = false + private(set) var isDeletingACL = false + private(set) var isLoadingLabels = false + private(set) var isSavingLabel = false + private(set) var isDeletingLabel = false + + var error: String? + var didDeleteTracker = false + + private let client: SRHTClient + + init(tracker: TrackerSummary, client: SRHTClient) { + self.tracker = tracker + self.client = client + } + + private static let trackerQuery = """ + query tracker($rid: ID!) { + tracker(rid: $rid) { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + } + """ + + private static let trackerACLsQuery = """ + query trackerACLs($rid: ID!, $cursor: Cursor) { + tracker(rid: $rid) { + defaultACL { + browse + submit + comment + edit + triage + } + acls(cursor: $cursor) { + results { + id + created + entity { canonicalName } + browse + submit + comment + edit + triage + } + cursor + } + } + } + """ + + private static let trackerLabelsQuery = """ + query trackerLabels($rid: ID!, $cursor: Cursor) { + tracker(rid: $rid) { + labels(cursor: $cursor) { + results { + id + name + backgroundColor + foregroundColor + } + cursor + } + } + } + """ + + private static let updateTrackerMutation = """ + mutation updateTracker($id: Int!, $input: TrackerInput!) { + updateTracker(id: $id, input: $input) { + id + rid + name + description + visibility + updated + owner { canonicalName } + } + } + """ + + private static let deleteTrackerMutation = """ + mutation deleteTracker($id: Int!) { + deleteTracker(id: $id) { + id + } + } + """ + + private static let updateUserACLMutation = """ + mutation updateUserACL($trackerId: Int!, $userId: Int!, $input: ACLInput!) { + updateUserACL(trackerId: $trackerId, userId: $userId, input: $input) { + id + created + entity { canonicalName } + browse + submit + comment + edit + triage + } + } + """ + + private static let updateTrackerACLMutation = """ + mutation updateTrackerACL($trackerId: Int!, $input: ACLInput!) { + updateTrackerACL(trackerId: $trackerId, input: $input) { + browse + submit + comment + edit + triage + } + } + """ + + private static let deleteACLMutation = """ + mutation deleteACL($id: Int!) { + deleteACL(id: $id) { + id + created + entity { canonicalName } + browse + submit + comment + edit + triage + } + } + """ + + private static let createLabelMutation = """ + mutation createLabel($trackerId: Int!, $name: String!, $foregroundColor: String!, $backgroundColor: String!) { + createLabel(trackerId: $trackerId, name: $name, foregroundColor: $foregroundColor, backgroundColor: $backgroundColor) { + id + name + backgroundColor + foregroundColor + } + } + """ + + private static let updateLabelMutation = """ + mutation updateLabel($id: Int!, $input: UpdateLabelInput!) { + updateLabel(id: $id, input: $input) { + id + name + backgroundColor + foregroundColor + } + } + """ + + private static let deleteLabelMutation = """ + mutation deleteLabel($id: Int!) { + deleteLabel(id: $id) { + id + name + backgroundColor + foregroundColor + } + } + """ + + private static let userLookupQuery = """ + query userLookup($username: String!) { + user(username: $username) { + id + } + } + """ + + func refreshTracker() async { + do { + let result = try await client.execute( + service: .todo, + query: Self.trackerQuery, + variables: ["rid": tracker.rid], + responseType: TrackerQueryResponse.self + ) + if let tracker = result.tracker { + self.tracker = tracker + } + } catch { + self.error = error.userFacingMessage + } + } + + func updateTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? { + guard !isSavingTracker else { return nil } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + error = "Enter a tracker name." + return nil + } + + isSavingTracker = true + error = nil + defer { isSavingTracker = false } + + var input: [String: any Sendable] = [ + "name": trimmedName, + "visibility": visibility.rawValue + ] + let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) + input["description"] = trimmedDescription.isEmpty ? "" : trimmedDescription + + do { + let result = try await client.execute( + service: .todo, + query: Self.updateTrackerMutation, + variables: [ + "id": tracker.id, + "input": input + ], + responseType: UpdateTrackerResponse.self + ) + tracker = result.updateTracker + return result.updateTracker + } catch { + self.error = "Couldn’t update the tracker. \(error.userFacingMessage)" + return nil + } + } + + func deleteTracker() async -> Bool { + guard !isDeletingTracker else { return false } + isDeletingTracker = true + error = nil + defer { isDeletingTracker = false } + + do { + _ = try await client.execute( + service: .todo, + query: Self.deleteTrackerMutation, + variables: ["id": tracker.id], + responseType: DeleteTrackerResponse.self + ) + didDeleteTracker = true + return true + } catch { + self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)" + return false + } + } + + func loadACLs() async { + guard !isLoadingACLs else { return } + isLoadingACLs = true + error = nil + defer { isLoadingACLs = false } + + do { + let result = try await client.execute( + service: .todo, + query: Self.trackerACLsQuery, + variables: ["rid": tracker.rid], + responseType: TrackerACLQueryResponse.self + ) + defaultACL = result.tracker?.defaultACL ?? defaultACL + acls = result.tracker?.acls.results ?? [] + } catch { + self.error = error.userFacingMessage + } + } + + func updateDefaultACL(_ permissions: TrackerACLPermissions) async -> Bool { + guard !isSavingACL else { return false } + isSavingACL = true + error = nil + defer { isSavingACL = false } + + do { + let result = try await client.execute( + service: .todo, + query: Self.updateTrackerACLMutation, + variables: [ + "trackerId": tracker.id, + "input": permissions.graphQLInput + ], + responseType: UpdateTrackerACLResponse.self + ) + defaultACL = result.updateTrackerACL + await loadACLs() + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + func addOrUpdateACL(username: String, permissions: TrackerACLPermissions) async -> Bool { + guard !isSavingACL else { return false } + let normalizedUsername = Self.normalizedUsername(username) + guard !normalizedUsername.isEmpty else { + error = "Enter a SourceHut username." + return false + } + + isSavingACL = true + error = nil + defer { isSavingACL = false } + + do { + let userResult = try await client.execute( + service: .todo, + query: Self.userLookupQuery, + variables: ["username": normalizedUsername], + responseType: TrackerUserLookupResponse.self + ) + guard let userId = userResult.user?.id else { + error = "That user couldn’t be found." + return false + } + + let result = try await client.execute( + service: .todo, + query: Self.updateUserACLMutation, + variables: [ + "trackerId": tracker.id, + "userId": userId, + "input": permissions.graphQLInput + ], + responseType: UpdateUserACLResponse.self + ) + if let index = acls.firstIndex(where: { $0.id == result.updateUserACL.id }) { + acls[index] = result.updateUserACL + } else { + acls.append(result.updateUserACL) + acls.sort { $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending } + } + await loadACLs() + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + func deleteACL(_ entry: TrackerACL) async { + guard !isDeletingACL else { return } + isDeletingACL = true + error = nil + defer { isDeletingACL = false } + + do { + _ = try await client.execute( + service: .todo, + query: Self.deleteACLMutation, + variables: ["id": entry.id], + responseType: DeleteTrackerACLResponse.self + ) + acls.removeAll { $0.id == entry.id } + await loadACLs() + } catch { + self.error = error.userFacingMessage + } + } + + func loadLabels() async { + guard !isLoadingLabels else { return } + isLoadingLabels = true + error = nil + defer { isLoadingLabels = false } + + do { + let result = try await client.execute( + service: .todo, + query: Self.trackerLabelsQuery, + variables: ["rid": tracker.rid], + responseType: TrackerLabelQueryResponse.self + ) + labels = result.tracker?.labels.results ?? [] + } catch { + self.error = error.userFacingMessage + } + } + + func createLabel(name: String, foregroundColor: String, backgroundColor: String) async -> Bool { + guard !isSavingLabel else { return false } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + error = "Enter a label name." + return false + } + guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else { + error = "Label colors must use #RRGGBB format." + return false + } + + isSavingLabel = true + error = nil + defer { isSavingLabel = false } + + do { + _ = try await client.execute( + service: .todo, + query: Self.createLabelMutation, + variables: [ + "trackerId": tracker.id, + "name": trimmedName, + "foregroundColor": foregroundColor, + "backgroundColor": backgroundColor + ], + responseType: CreateTrackerLabelResponse.self + ) + await loadLabels() + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + func updateLabel( + _ label: TicketLabel, + name: String, + foregroundColor: String, + backgroundColor: String + ) async -> Bool { + guard !isSavingLabel else { return false } + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + error = "Enter a label name." + return false + } + guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else { + error = "Label colors must use #RRGGBB format." + return false + } + + isSavingLabel = true + error = nil + defer { isSavingLabel = false } + + var input: [String: any Sendable] = [:] + if trimmedName != label.name { + input["name"] = trimmedName + } + if foregroundColor.caseInsensitiveCompare(label.foregroundColor) != .orderedSame { + input["foregroundColor"] = foregroundColor + } + if backgroundColor.caseInsensitiveCompare(label.backgroundColor) != .orderedSame { + input["backgroundColor"] = backgroundColor + } + + guard !input.isEmpty else { return true } + + do { + _ = try await client.execute( + service: .todo, + query: Self.updateLabelMutation, + variables: [ + "id": label.id, + "input": input + ], + responseType: UpdateTrackerLabelResponse.self + ) + await loadLabels() + return true + } catch { + self.error = error.userFacingMessage + return false + } + } + + func deleteLabel(_ label: TicketLabel) async { + guard !isDeletingLabel else { return } + isDeletingLabel = true + error = nil + defer { isDeletingLabel = false } + + do { + _ = try await client.execute( + service: .todo, + query: Self.deleteLabelMutation, + variables: ["id": label.id], + responseType: DeleteTrackerLabelResponse.self + ) + labels.removeAll { $0.id == label.id } + await loadLabels() + } catch { + self.error = error.userFacingMessage + } + } + + static func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } + + static func isValidHexColor(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count == 7, trimmed.first == "#" else { return false } + return trimmed.dropFirst().allSatisfy { $0.isHexDigit } + } +} + +private extension TrackerACLPermissions { + var graphQLInput: [String: any Sendable] { + [ + "browse": browse, + "submit": submit, + "comment": comment, + "edit": edit, + "triage": triage + ] + } +} + +struct TrackerEditorSheet: View { + let title: String + let confirmationTitle: String + let isSaving: Bool + let error: String? + let initialName: String + let initialDescription: String + let initialVisibility: Visibility + let onSave: (String, String, Visibility) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var description: String + @State private var visibility: Visibility + + init( + title: String, + confirmationTitle: String, + isSaving: Bool, + error: String?, + initialName: String, + initialDescription: String, + initialVisibility: Visibility, + onSave: @escaping (String, String, Visibility) async -> Bool + ) { + self.title = title + self.confirmationTitle = confirmationTitle + self.isSaving = isSaving + self.error = error + self.initialName = initialName + self.initialDescription = initialDescription + self.initialVisibility = initialVisibility + self.onSave = onSave + _name = State(initialValue: initialName) + _description = State(initialValue: initialDescription) + _visibility = State(initialValue: initialVisibility) + } + + var body: some View { + NavigationStack { + Form { + Section("Tracker Details") { + TextField("Tracker name", text: $name) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Short description (optional)", text: $description, axis: .vertical) + .lineLimit(2...4) + Picker("Visibility", selection: $visibility) { + Text("Public").tag(Visibility.public) + Text("Unlisted").tag(Visibility.unlisted) + Text("Private").tag(Visibility.private) + } + } + + if let error, !error.isEmpty { + Section { + Text(error) + .foregroundStyle(.red) + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let didSave = await onSave(name, description, visibility) + if didSave { + dismiss() + } + } + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Text(confirmationTitle) + } + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) + } + } + } + } +} + +struct TrackerACLManagementSheet: View { + let viewModel: TrackerManagementViewModel + + @Bindable private var bindableViewModel: TrackerManagementViewModel + @State private var editingACL: TrackerACL? + @State private var editingDefaultACL = false + @State private var pendingDeletion: TrackerACL? + @State private var showCreateACL = false + + init(viewModel: TrackerManagementViewModel) { + self.viewModel = viewModel + self._bindableViewModel = Bindable(viewModel) + } + + var body: some View { + NavigationStack { + List { + Section("Default Access") { + TrackerPermissionSummary(permissions: viewModel.defaultACL.permissions) + Button("Update Default ACL") { + editingDefaultACL = true + } + .disabled(viewModel.isSavingACL) + } + + Section { + if viewModel.isLoadingACLs { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if viewModel.acls.isEmpty { + Text("No tracker-specific ACLs yet.") + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.acls) { entry in + VStack(alignment: .leading, spacing: 6) { + Text(entry.entity.canonicalName) + .font(.subheadline.weight(.medium)) + TrackerPermissionSummary(permissions: entry.permissions) + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + editingACL = entry + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + pendingDeletion = entry + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } header: { + Text("User ACLs") + } footer: { + Text("Each ACL must include all five permission flags.") + } + } + .navigationTitle("ACLs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreateACL = true + } label: { + Image(systemName: "plus") + } + .disabled(viewModel.isSavingACL) + } + } + .task { + await viewModel.loadACLs() + } + .srhtErrorBanner(error: $bindableViewModel.error) + .sheet(isPresented: $showCreateACL) { + TrackerACLEditorSheet( + title: "Add ACL", + submitTitle: "Save", + isSaving: viewModel.isSavingACL, + error: viewModel.error, + initialUsername: "", + initialPermissions: viewModel.defaultACL.permissions + ) { username, permissions in + await viewModel.addOrUpdateACL(username: username, permissions: permissions) + } + } + .sheet(item: $editingACL) { entry in + TrackerACLEditorSheet( + title: "Update ACL", + submitTitle: "Save", + isSaving: viewModel.isSavingACL, + error: viewModel.error, + initialUsername: entry.entity.canonicalName, + initialPermissions: entry.permissions + ) { username, permissions in + await viewModel.addOrUpdateACL(username: username, permissions: permissions) + } + } + .sheet(isPresented: $editingDefaultACL) { + TrackerDefaultACLEditorSheet( + isSaving: viewModel.isSavingACL, + error: viewModel.error, + initialPermissions: viewModel.defaultACL.permissions + ) { permissions in + await viewModel.updateDefaultACL(permissions) + } + } + .alert("Remove Access?", isPresented: Binding( + get: { pendingDeletion != nil }, + set: { isPresented in + if !isPresented { + pendingDeletion = nil + } + } + )) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + guard let pendingDeletion else { return } + Task { + await viewModel.deleteACL(pendingDeletion) + self.pendingDeletion = nil + } + } + } message: { + if let pendingDeletion { + Text("\(pendingDeletion.entity.canonicalName) will fall back to the tracker default ACL.") + } + } + } + } +} + +private struct TrackerPermissionSummary: View { + let permissions: TrackerACLPermissions + + var body: some View { + Text(summary) + .font(.caption) + .foregroundStyle(.secondary) + } + + private var summary: String { + let items = [ + permissions.browse ? "browse" : nil, + permissions.submit ? "submit" : nil, + permissions.comment ? "comment" : nil, + permissions.edit ? "edit" : nil, + permissions.triage ? "triage" : nil + ].compactMap { $0 } + return items.isEmpty ? "No permissions" : items.joined(separator: ", ") + } +} + +private struct TrackerACLEditorSheet: View { + let title: String + let submitTitle: String + let isSaving: Bool + let error: String? + let initialUsername: String + let initialPermissions: TrackerACLPermissions + let onSave: (String, TrackerACLPermissions) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var username: String + @State private var browse: Bool + @State private var submit: Bool + @State private var comment: Bool + @State private var edit: Bool + @State private var triage: Bool + + init( + title: String, + submitTitle: String, + isSaving: Bool, + error: String?, + initialUsername: String, + initialPermissions: TrackerACLPermissions, + onSave: @escaping (String, TrackerACLPermissions) async -> Bool + ) { + self.title = title + self.submitTitle = submitTitle + self.isSaving = isSaving + self.error = error + self.initialUsername = initialUsername + self.initialPermissions = initialPermissions + self.onSave = onSave + _username = State(initialValue: initialUsername) + _browse = State(initialValue: initialPermissions.browse) + _submit = State(initialValue: initialPermissions.submit) + _comment = State(initialValue: initialPermissions.comment) + _edit = State(initialValue: initialPermissions.edit) + _triage = State(initialValue: initialPermissions.triage) + } + + var body: some View { + NavigationStack { + Form { + Section("User") { + TextField("Username or ~username", text: $username) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + + permissionSection + + if let error, !error.isEmpty { + Section { + Text(error) + .foregroundStyle(.red) + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let didSave = await onSave(username, permissions) + if didSave { + dismiss() + } + } + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Text(submitTitle) + } + } + .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) + } + } + } + } + + private var permissionSection: some View { + Section("Permissions") { + Toggle("Browse", isOn: $browse) + Toggle("Submit", isOn: $submit) + Toggle("Comment", isOn: $comment) + Toggle("Edit", isOn: $edit) + Toggle("Triage", isOn: $triage) + } + } + + private var permissions: TrackerACLPermissions { + TrackerACLPermissions( + browse: browse, + submit: submit, + comment: comment, + edit: edit, + triage: triage + ) + } +} + +private struct TrackerDefaultACLEditorSheet: View { + let isSaving: Bool + let error: String? + let initialPermissions: TrackerACLPermissions + let onSave: (TrackerACLPermissions) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var browse: Bool + @State private var submit: Bool + @State private var comment: Bool + @State private var edit: Bool + @State private var triage: Bool + + init( + isSaving: Bool, + error: String?, + initialPermissions: TrackerACLPermissions, + onSave: @escaping (TrackerACLPermissions) async -> Bool + ) { + self.isSaving = isSaving + self.error = error + self.initialPermissions = initialPermissions + self.onSave = onSave + _browse = State(initialValue: initialPermissions.browse) + _submit = State(initialValue: initialPermissions.submit) + _comment = State(initialValue: initialPermissions.comment) + _edit = State(initialValue: initialPermissions.edit) + _triage = State(initialValue: initialPermissions.triage) + } + + var body: some View { + NavigationStack { + Form { + Section("Permissions") { + Toggle("Browse", isOn: $browse) + Toggle("Submit", isOn: $submit) + Toggle("Comment", isOn: $comment) + Toggle("Edit", isOn: $edit) + Toggle("Triage", isOn: $triage) + } + + if let error, !error.isEmpty { + Section { + Text(error) + .foregroundStyle(.red) + } + } + } + .navigationTitle("Default ACL") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let didSave = await onSave( + TrackerACLPermissions( + browse: browse, + submit: submit, + comment: comment, + edit: edit, + triage: triage + ) + ) + if didSave { + dismiss() + } + } + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Text("Save") + } + } + .disabled(isSaving) + } + } + } + } +} + +struct TrackerLabelManagementSheet: View { + let viewModel: TrackerManagementViewModel + + @Bindable private var bindableViewModel: TrackerManagementViewModel + @State private var showCreateLabel = false + @State private var editingLabel: TicketLabel? + @State private var pendingDeletion: TicketLabel? + + init(viewModel: TrackerManagementViewModel) { + self.viewModel = viewModel + self._bindableViewModel = Bindable(viewModel) + } + + var body: some View { + NavigationStack { + List { + if viewModel.isLoadingLabels { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if viewModel.labels.isEmpty { + ContentUnavailableView( + "No Labels", + systemImage: "tag", + description: Text("Create labels for triage and organization.") + ) + } else { + ForEach(viewModel.labels) { label in + HStack { + LabelPill(label: label) + Spacer() + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + editingLabel = label + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + + Button(role: .destructive) { + pendingDeletion = label + } label: { + Label("Delete", systemImage: "trash") + } + } + } + } + } + .navigationTitle("Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showCreateLabel = true + } label: { + Image(systemName: "plus") + } + .disabled(viewModel.isSavingLabel) + } + } + .task { + await viewModel.loadLabels() + } + .srhtErrorBanner(error: $bindableViewModel.error) + .sheet(isPresented: $showCreateLabel) { + TrackerLabelEditorSheet( + title: "New Label", + submitTitle: "Create", + isSaving: viewModel.isSavingLabel, + error: viewModel.error, + initialLabel: nil + ) { name, foreground, background in + await viewModel.createLabel( + name: name, + foregroundColor: foreground, + backgroundColor: background + ) + } + } + .sheet(item: $editingLabel) { label in + TrackerLabelEditorSheet( + title: "Update Label", + submitTitle: "Save", + isSaving: viewModel.isSavingLabel, + error: viewModel.error, + initialLabel: label + ) { name, foreground, background in + await viewModel.updateLabel( + label, + name: name, + foregroundColor: foreground, + backgroundColor: background + ) + } + } + .alert("Delete Label?", isPresented: Binding( + get: { pendingDeletion != nil }, + set: { isPresented in + if !isPresented { + pendingDeletion = nil + } + } + )) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + guard let pendingDeletion else { return } + Task { + await viewModel.deleteLabel(pendingDeletion) + self.pendingDeletion = nil + } + } + } message: { + if let pendingDeletion { + Text("“\(pendingDeletion.name)” will be removed from this tracker and from any tickets using it.") + } + } + } + } +} + +private struct TrackerLabelEditorSheet: View { + let title: String + let submitTitle: String + let isSaving: Bool + let error: String? + let initialLabel: TicketLabel? + let onSave: (String, String, String) async -> Bool + + @Environment(\.dismiss) private var dismiss + @State private var name: String + @State private var foregroundColor: Color + @State private var backgroundColor: Color + + init( + title: String, + submitTitle: String, + isSaving: Bool, + error: String?, + initialLabel: TicketLabel?, + onSave: @escaping (String, String, String) async -> Bool + ) { + self.title = title + self.submitTitle = submitTitle + self.isSaving = isSaving + self.error = error + self.initialLabel = initialLabel + self.onSave = onSave + _name = State(initialValue: initialLabel?.name ?? "") + _foregroundColor = State(initialValue: Color(hex: initialLabel?.foregroundColor ?? "#ffffff") ?? .white) + _backgroundColor = State(initialValue: Color(hex: initialLabel?.backgroundColor ?? "#000000") ?? .black) + } + + var body: some View { + NavigationStack { + Form { + Section("Details") { + TextField("Label name", text: $name) + ColorPicker("Foreground", selection: $foregroundColor, supportsOpacity: false) + ColorPicker("Background", selection: $backgroundColor, supportsOpacity: false) + } + + Section("Preview") { + LabelPill( + label: TicketLabel( + id: initialLabel?.id ?? -1, + name: name.isEmpty ? "Preview" : name, + backgroundColor: backgroundColor.hexString, + foregroundColor: foregroundColor.hexString + ) + ) + } + + if let error, !error.isEmpty { + Section { + Text(error) + .foregroundStyle(.red) + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button { + Task { + let didSave = await onSave( + name, + foregroundColor.hexString, + backgroundColor.hexString + ) + if didSave { + dismiss() + } + } + } label: { + if isSaving { + ProgressView() + .controlSize(.small) + } else { + Text(submitTitle) + } + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving) + } + } + } + } +} diff --git a/HutchTests/TrackerManagementViewModelTests.swift b/HutchTests/TrackerManagementViewModelTests.swift new file mode 100644 index 0000000..78edfc9 --- /dev/null +++ b/HutchTests/TrackerManagementViewModelTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import Hutch + +struct TrackerManagementViewModelTests { + + @Test + func normalizedUsernameStripsLeadingTildeAndWhitespace() { + #expect(TrackerManagementViewModel.normalizedUsername(" ~alice ") == "alice") + #expect(TrackerManagementViewModel.normalizedUsername("bob") == "bob") + } + + @Test + func hexColorValidationRequiresPoundAndSixHexDigits() { + #expect(TrackerManagementViewModel.isValidHexColor("#a1B2c3")) + #expect(!TrackerManagementViewModel.isValidHexColor("a1B2c3")) + #expect(!TrackerManagementViewModel.isValidHexColor("#12345")) + #expect(!TrackerManagementViewModel.isValidHexColor("#12GG45")) + } + + @Test + func trackerACLPermissionsExposeBooleanFlags() { + let permissions = TrackerACLPermissions( + browse: true, + submit: false, + comment: true, + edit: false, + triage: true + ) + + #expect(permissions.browse) + #expect(!permissions.submit) + #expect(permissions.comment) + #expect(!permissions.edit) + #expect(permissions.triage) + } +} -- cgit v1.2.3