summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Tickets')
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift124
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift142
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift60
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift199
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift112
-rw-r--r--Hutch/Views/Tickets/TrackerManagementView.swift1268
6 files changed, 1690 insertions, 215 deletions
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)
+ }
+ }
+ }
+ }
+}