Commit f3a053dfa5

f3a053dfa5d5e6bd50e9e17e23eeb65260d59f91

parent: 2dd2805e4a

Unsigned

cmc <hello@cleberg.net> · 2026-04-02 03:03 UTC

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

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -495,7 +495,7 @@
495495 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
496496 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
497497 CODE_SIGN_STYLE = Automatic;
498 CURRENT_PROJECT_VERSION = 16;
498 CURRENT_PROJECT_VERSION = 17;
499499 DEVELOPMENT_TEAM = ZCNAX3VL9D;
500500 ENABLE_PREVIEWS = YES;
501501 GENERATE_INFOPLIST_FILE = YES;
@@ -512,7 +512,7 @@
512512 "$(inherited)",
513513 "@executable_path/Frameworks",
514514 );
515 MARKETING_VERSION = 2.6.1;
515 MARKETING_VERSION = 2.7.0;
516516 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
517517 PRODUCT_NAME = "$(TARGET_NAME)";
518518 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -532,7 +532,7 @@
532532 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
533533 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
534534 CODE_SIGN_STYLE = Automatic;
535 CURRENT_PROJECT_VERSION = 16;
535 CURRENT_PROJECT_VERSION = 17;
536536 DEVELOPMENT_TEAM = ZCNAX3VL9D;
537537 ENABLE_PREVIEWS = YES;
538538 GENERATE_INFOPLIST_FILE = YES;
@@ -549,7 +549,7 @@
549549 "$(inherited)",
550550 "@executable_path/Frameworks",
551551 );
552 MARKETING_VERSION = 2.6.1;
552 MARKETING_VERSION = 2.7.0;
553553 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
554554 PRODUCT_NAME = "$(TARGET_NAME)";
555555 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -612,7 +612,7 @@
612612 APPLICATION_EXTENSION_API_ONLY = YES;
613613 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
614614 CODE_SIGN_STYLE = Automatic;
615 CURRENT_PROJECT_VERSION = 16;
615 CURRENT_PROJECT_VERSION = 17;
616616 DEVELOPMENT_TEAM = ZCNAX3VL9D;
617617 GENERATE_INFOPLIST_FILE = NO;
618618 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -622,7 +622,7 @@
622622 "@executable_path/Frameworks",
623623 "@executable_path/../../Frameworks",
624624 );
625 MARKETING_VERSION = 2.6.1;
625 MARKETING_VERSION = 2.7.0;
626626 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
627627 PRODUCT_NAME = "$(TARGET_NAME)";
628628 SKIP_INSTALL = YES;
@@ -641,7 +641,7 @@
641641 APPLICATION_EXTENSION_API_ONLY = YES;
642642 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
643643 CODE_SIGN_STYLE = Automatic;
644 CURRENT_PROJECT_VERSION = 16;
644 CURRENT_PROJECT_VERSION = 17;
645645 DEVELOPMENT_TEAM = ZCNAX3VL9D;
646646 GENERATE_INFOPLIST_FILE = NO;
647647 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -651,7 +651,7 @@
651651 "@executable_path/Frameworks",
652652 "@executable_path/../../Frameworks",
653653 );
654 MARKETING_VERSION = 2.6.1;
654 MARKETING_VERSION = 2.7.0;
655655 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
656656 PRODUCT_NAME = "$(TARGET_NAME)";
657657 SKIP_INSTALL = YES;
Hutch/Models/Todo.swift +49
@@ -81,6 +81,55 @@ struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
8181 let foregroundColor: String
8282}
8383
84// MARK: - Tracker ACL
85
86struct TrackerACLPermissions: Codable, Sendable, Hashable {
87 let browse: Bool
88 let submit: Bool
89 let comment: Bool
90 let edit: Bool
91 let triage: Bool
92}
93
94struct TrackerACL: Codable, Sendable, Identifiable, Hashable {
95 let id: Int
96 let created: Date
97 let entity: Entity
98 let browse: Bool
99 let submit: Bool
100 let comment: Bool
101 let edit: Bool
102 let triage: Bool
103
104 var permissions: TrackerACLPermissions {
105 TrackerACLPermissions(
106 browse: browse,
107 submit: submit,
108 comment: comment,
109 edit: edit,
110 triage: triage
111 )
112 }
113}
114
115struct DefaultTrackerACL: Codable, Sendable, Hashable {
116 let browse: Bool
117 let submit: Bool
118 let comment: Bool
119 let edit: Bool
120 let triage: Bool
121
122 var permissions: TrackerACLPermissions {
123 TrackerACLPermissions(
124 browse: browse,
125 submit: submit,
126 comment: comment,
127 edit: edit,
128 triage: triage
129 )
130 }
131}
132
84133// MARK: - Tracker
85134
86135/// A bug tracker from todo.sr.ht.
Hutch/Views/Tickets/TicketDetailViewModel.swift +54 −70
@@ -3,10 +3,6 @@ import Foundation
33// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
44
55private struct TicketDetailResponse: Decodable, Sendable {
6 let user: UserTrackerTicketWrapper
7}
8
9private struct UserTrackerTicketWrapper: Decodable, Sendable {
106 let tracker: TrackerTicketWrapper
117}
128
@@ -85,10 +81,6 @@ private struct CreateLabelResponse: Decodable, Sendable {
8581}
8682
8783private struct TrackerLabelsResponse: Decodable, Sendable {
88 let user: UserTrackerLabelsWrapper
89}
90
91private struct UserTrackerLabelsWrapper: Decodable, Sendable {
9284 let tracker: TrackerLabelsWrapper
9385}
9486
@@ -155,57 +147,55 @@ final class TicketDetailViewModel {
155147 // MARK: - Queries
156148
157149 private static let detailQuery = """
158 query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
159 user(username: $owner) {
160 tracker(name: $tracker) {
161 ticket(id: $ticketId) {
162 id
163 created
164 updated
165 title: subject
166 description: body
167 status
168 resolution
169 authenticity
170 submitter { canonicalName }
171 assignees { canonicalName }
172 labels { id name backgroundColor foregroundColor }
173 events {
174 results {
175 id
176 created
177 changes {
178 eventType
179 ... on Comment {
180 author { canonicalName }
181 text
182 authenticity
183 }
184 ... on StatusChange {
185 oldStatus
186 newStatus
187 }
188 ... on LabelUpdate {
189 labeler { canonicalName }
190 label { name }
191 }
192 ... on Assignment {
193 assigner { canonicalName }
194 assignee { canonicalName }
195 }
196 ... on TicketMention {
197 mentioned { id }
198 }
199 ... on UserMention {
200 mentioned { canonicalName }
201 }
202 ... on Created {
203 author { canonicalName }
204 }
150 query ticket($rid: ID!, $ticketId: Int!) {
151 tracker(rid: $rid) {
152 ticket(id: $ticketId) {
153 id
154 created
155 updated
156 title: subject
157 description: body
158 status
159 resolution
160 authenticity
161 submitter { canonicalName }
162 assignees { canonicalName }
163 labels { id name backgroundColor foregroundColor }
164 events {
165 results {
166 id
167 created
168 changes {
169 eventType
170 ... on Comment {
171 author { canonicalName }
172 text
173 authenticity
174 }
175 ... on StatusChange {
176 oldStatus
177 newStatus
178 }
179 ... on LabelUpdate {
180 labeler { canonicalName }
181 label { name }
182 }
183 ... on Assignment {
184 assigner { canonicalName }
185 assignee { canonicalName }
186 }
187 ... on TicketMention {
188 mentioned { id }
189 }
190 ... on UserMention {
191 mentioned { canonicalName }
192 }
193 ... on Created {
194 author { canonicalName }
205195 }
206196 }
207 cursor
208197 }
198 cursor
209199 }
210200 }
211201 }
@@ -268,12 +258,10 @@ final class TicketDetailViewModel {
268258 """
269259
270260 private static let trackerLabelsQuery = """
271 query trackerLabels($owner: String!, $tracker: String!) {
272 user(username: $owner) {
273 tracker(name: $tracker) {
274 labels {
275 results { id name backgroundColor foregroundColor }
276 }
261 query trackerLabels($rid: ID!) {
262 tracker(rid: $rid) {
263 labels {
264 results { id name backgroundColor foregroundColor }
277265 }
278266 }
279267 }
@@ -302,13 +290,12 @@ final class TicketDetailViewModel {
302290 service: .todo,
303291 query: Self.detailQuery,
304292 variables: [
305 "owner": ownerUsername,
306 "tracker": trackerName,
293 "rid": trackerRid,
307294 "ticketId": ticketId
308295 ],
309296 responseType: TicketDetailResponse.self
310297 )
311 let payload = result.user.tracker.ticket
298 let payload = result.tracker.ticket
312299 ticket = TicketDetail(
313300 id: payload.id,
314301 created: payload.created,
@@ -579,13 +566,10 @@ final class TicketDetailViewModel {
579566 let result = try await client.execute(
580567 service: .todo,
581568 query: Self.trackerLabelsQuery,
582 variables: [
583 "owner": ownerUsername,
584 "tracker": trackerName
585 ],
569 variables: ["rid": trackerRid],
586570 responseType: TrackerLabelsResponse.self
587571 )
588 trackerLabels = result.user.tracker.labels.results
572 trackerLabels = result.tracker.labels.results
589573 } catch {
590574 self.error = error.userFacingMessage
591575 }
Hutch/Views/Tickets/TicketListView.swift +130 −12
@@ -1,17 +1,32 @@
11import SwiftUI
22
33struct TicketListView: View {
4 let ownerUsername: String
5 let trackerName: String
6 let trackerId: Int
7 let trackerRid: String
4 let onTrackerUpdated: (TrackerSummary) -> Void
5 let onTrackerDeleted: (TrackerSummary) -> Void
86
97 @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true
108 @Environment(AppState.self) private var appState
9 @Environment(\.dismiss) private var dismiss
10 @State private var tracker: TrackerSummary
1111 @State private var viewModel: TicketListViewModel?
12 @State private var trackerManagementViewModel: TrackerManagementViewModel?
1213 @State private var showCreateTicketSheet = false
1314 @State private var createdTicket: TicketSummary?
1415 @State private var labelEditorTicket: LabelEditorTicket?
16 @State private var showTrackerEditor = false
17 @State private var showTrackerACLs = false
18 @State private var showTrackerLabels = false
19 @State private var showDeleteTrackerConfirmation = false
20
21 init(
22 tracker: TrackerSummary,
23 onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in },
24 onTrackerDeleted: @escaping (TrackerSummary) -> Void = { _ in }
25 ) {
26 self._tracker = State(initialValue: tracker)
27 self.onTrackerUpdated = onTrackerUpdated
28 self.onTrackerDeleted = onTrackerDeleted
29 }
1530
1631 var body: some View {
1732 Group {
@@ -21,11 +36,17 @@ struct TicketListView: View {
2136 SRHTLoadingStateView(message: "Loading tickets…")
2237 }
2338 }
24 .navigationTitle(trackerName)
39 .navigationTitle(tracker.name)
2540 .navigationBarTitleDisplayMode(.inline)
2641 .toolbar {
2742 ToolbarItemGroup(placement: .topBarTrailing) {
28 SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) {
43 SRHTShareButton(
44 url: SRHTWebURL.tracker(
45 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
46 trackerName: tracker.name
47 ),
48 target: .tracker
49 ) {
2950 Image(systemName: "square.and.arrow.up")
3051 }
3152
@@ -36,6 +57,8 @@ struct TicketListView: View {
3657 Image(systemName: "plus")
3758 }
3859 .accessibilityLabel("Create ticket")
60
61 trackerActionsMenu
3962 }
4063 }
4164 }
@@ -56,6 +79,57 @@ struct TicketListView: View {
5679 .presentationDetents([.medium])
5780 }
5881 }
82 .sheet(isPresented: $showTrackerEditor) {
83 if let trackerManagementViewModel {
84 TrackerEditorSheet(
85 title: "Update Tracker",
86 confirmationTitle: "Save",
87 isSaving: trackerManagementViewModel.isSavingTracker,
88 error: trackerManagementViewModel.error,
89 initialName: tracker.name,
90 initialDescription: tracker.description ?? "",
91 initialVisibility: tracker.visibility
92 ) { name, description, visibility in
93 if let updatedTracker = await trackerManagementViewModel.updateTracker(
94 name: name,
95 description: description,
96 visibility: visibility
97 ) {
98 tracker = updatedTracker
99 onTrackerUpdated(updatedTracker)
100 return true
101 }
102 return false
103 }
104 }
105 }
106 .sheet(isPresented: $showTrackerACLs) {
107 if let trackerManagementViewModel {
108 TrackerACLManagementSheet(viewModel: trackerManagementViewModel)
109 .presentationDetents([.large])
110 }
111 }
112 .sheet(isPresented: $showTrackerLabels) {
113 if let trackerManagementViewModel {
114 TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
115 .presentationDetents([.large])
116 }
117 }
118 .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
119 Button("Cancel", role: .cancel) {}
120 Button("Delete", role: .destructive) {
121 guard let trackerManagementViewModel else { return }
122 Task {
123 let didDelete = await trackerManagementViewModel.deleteTracker()
124 if didDelete {
125 onTrackerDeleted(tracker)
126 dismiss()
127 }
128 }
129 }
130 } message: {
131 Text("“\(tracker.name)” will be permanently deleted.")
132 }
59133 .navigationDestination(isPresented: Binding(
60134 get: { createdTicket != nil },
61135 set: { isPresented in
@@ -65,19 +139,26 @@ struct TicketListView: View {
65139 }
66140 )) {
67141 if let createdTicket {
68 TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id)
142 TicketDetailView(
143 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
144 trackerName: tracker.name,
145 trackerId: tracker.id,
146 trackerRid: tracker.rid,
147 ticketId: createdTicket.id
148 )
69149 }
70150 }
71151 .task {
72152 if viewModel == nil {
73153 let vm = TicketListViewModel(
74 ownerUsername: ownerUsername,
75 trackerName: trackerName,
76 trackerId: trackerId,
77 trackerRid: trackerRid,
154 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
155 trackerName: tracker.name,
156 trackerId: tracker.id,
157 trackerRid: tracker.rid,
78158 client: appState.client
79159 )
80160 viewModel = vm
161 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
81162 await vm.loadTickets()
82163 }
83164 }
@@ -163,8 +244,45 @@ struct TicketListView: View {
163244 await viewModel.loadTickets()
164245 }
165246 .navigationDestination(for: TicketSummary.self) { ticket in
166 TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
247 TicketDetailView(
248 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
249 trackerName: tracker.name,
250 trackerId: tracker.id,
251 trackerRid: tracker.rid,
252 ticketId: ticket.id
253 )
254 }
255 }
256
257 private var trackerActionsMenu: some View {
258 Menu {
259 Button {
260 showTrackerEditor = true
261 } label: {
262 Label("Update Tracker", systemImage: "pencil")
263 }
264
265 Button {
266 showTrackerACLs = true
267 } label: {
268 Label("Manage ACLs", systemImage: "person.2")
269 }
270
271 Button {
272 showTrackerLabels = true
273 } label: {
274 Label("Manage Labels", systemImage: "tag")
275 }
276
277 Button(role: .destructive) {
278 showDeleteTrackerConfirmation = true
279 } label: {
280 Label("Delete Tracker", systemImage: "trash")
281 }
282 } label: {
283 Image(systemName: "ellipsis.circle")
167284 }
285 .accessibilityLabel("Tracker actions")
168286 }
169287
170288 @ViewBuilder
Hutch/Views/Tickets/TicketListViewModel.swift +21 −39
@@ -3,10 +3,6 @@ import Foundation
33// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
44
55private struct TrackerTicketsResponse: Decodable, Sendable {
6 let user: UserTrackerWrapper
7}
8
9private struct UserTrackerWrapper: Decodable, Sendable {
106 let tracker: TrackerTicketsWrapper
117}
128
@@ -38,10 +34,6 @@ private struct LabelMutationResponse: Decodable, Sendable {
3834}
3935
4036private struct TrackerLabelsResponse: Decodable, Sendable {
41 let user: UserTrackerLabelsWrapper
42}
43
44private struct UserTrackerLabelsWrapper: Decodable, Sendable {
4537 let tracker: TrackerLabelsWrapper
4638}
4739
@@ -116,22 +108,20 @@ final class TicketListViewModel {
116108 // MARK: - Query
117109
118110 private static let query = """
119 query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
120 user(username: $owner) {
121 tracker(name: $tracker) {
122 tickets(cursor: $cursor) {
123 results {
124 id
125 title: subject
126 status
127 resolution
128 created
129 submitter { canonicalName }
130 labels { id name backgroundColor foregroundColor }
131 assignees { canonicalName }
132 }
133 cursor
111 query tickets($rid: ID!, $cursor: Cursor) {
112 tracker(rid: $rid) {
113 tickets(cursor: $cursor) {
114 results {
115 id
116 title: subject
117 status
118 resolution
119 created
120 submitter { canonicalName }
121 labels { id name backgroundColor foregroundColor }
122 assignees { canonicalName }
134123 }
124 cursor
135125 }
136126 }
137127 }
@@ -185,12 +175,10 @@ final class TicketListViewModel {
185175 """
186176
187177 private static let trackerLabelsQuery = """
188 query trackerLabels($owner: String!, $tracker: String!) {
189 user(username: $owner) {
190 tracker(name: $tracker) {
191 labels {
192 results { id name backgroundColor foregroundColor }
193 }
178 query trackerLabels($rid: ID!) {
179 tracker(rid: $rid) {
180 labels {
181 results { id name backgroundColor foregroundColor }
194182 }
195183 }
196184 }
@@ -404,13 +392,10 @@ final class TicketListViewModel {
404392 let result = try await client.execute(
405393 service: .todo,
406394 query: Self.trackerLabelsQuery,
407 variables: [
408 "owner": ownerUsername,
409 "tracker": trackerName
410 ],
395 variables: ["rid": trackerRid],
411396 responseType: TrackerLabelsResponse.self
412397 )
413 trackerLabels = result.user.tracker.labels.results
398 trackerLabels = result.tracker.labels.results
414399 } catch {
415400 self.error = error.userFacingMessage
416401 }
@@ -530,10 +515,7 @@ final class TicketListViewModel {
530515 }
531516
532517 private func fetchPage(cursor: String?) async throws -> TicketsPage {
533 var variables: [String: any Sendable] = [
534 "owner": ownerUsername,
535 "tracker": trackerName
536 ]
518 var variables: [String: any Sendable] = ["rid": trackerRid]
537519 if let cursor {
538520 variables["cursor"] = cursor
539521 }
@@ -543,7 +525,7 @@ final class TicketListViewModel {
543525 variables: variables,
544526 responseType: TrackerTicketsResponse.self
545527 )
546 return result.user.tracker.tickets
528 return result.tracker.tickets
547529 }
548530
549531 private struct SubmitTicketResponse: Decodable, Sendable {
Hutch/Views/Tickets/TrackerListView.swift +105 −94
@@ -5,6 +5,8 @@ struct TrackerListView: View {
55 @State private var viewModel: TrackerListViewModel?
66 @State private var showCreateTrackerSheet = false
77 @State private var createdTracker: TrackerSummary?
8 @State private var editingTracker: TrackerSummary?
9 @State private var pendingDeletion: TrackerSummary?
810
911 var body: some View {
1012 Group {
@@ -28,9 +30,50 @@ struct TrackerListView: View {
2830 }
2931 .sheet(isPresented: $showCreateTrackerSheet) {
3032 if let viewModel {
31 CreateTrackerSheet(viewModel: viewModel) { tracker in
32 showCreateTrackerSheet = false
33 createdTracker = tracker
33 TrackerEditorSheet(
34 title: "New Tracker",
35 confirmationTitle: "Create Tracker",
36 isSaving: viewModel.isCreatingTracker,
37 error: viewModel.error,
38 initialName: "",
39 initialDescription: "",
40 initialVisibility: .public
41 ) { name, description, visibility in
42 if let tracker = await viewModel.createTracker(
43 name: name,
44 description: description,
45 visibility: visibility
46 ) {
47 createdTracker = tracker
48 showCreateTrackerSheet = false
49 return true
50 }
51 return false
52 }
53 }
54 }
55 .sheet(item: $editingTracker) { tracker in
56 if let viewModel {
57 TrackerEditorSheet(
58 title: "Update Tracker",
59 confirmationTitle: "Save",
60 isSaving: viewModel.isCreatingTracker,
61 error: viewModel.error,
62 initialName: tracker.name,
63 initialDescription: tracker.description ?? "",
64 initialVisibility: tracker.visibility
65 ) { name, description, visibility in
66 if let updatedTracker = await viewModel.updateTracker(
67 tracker,
68 name: name,
69 description: description,
70 visibility: visibility
71 ) {
72 createdTracker = createdTracker?.id == tracker.id ? updatedTracker : createdTracker
73 editingTracker = nil
74 return true
75 }
76 return false
3477 }
3578 }
3679 }
@@ -43,12 +86,42 @@ struct TrackerListView: View {
4386 }
4487 )) {
4588 if let createdTracker {
46 TicketListView(
47 ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
48 trackerName: createdTracker.name,
49 trackerId: createdTracker.id,
50 trackerRid: createdTracker.rid
51 )
89 TicketListView(tracker: createdTracker) { updatedTracker in
90 viewModel?.applyTrackerUpdate(updatedTracker)
91 self.createdTracker = updatedTracker
92 } onTrackerDeleted: { deletedTracker in
93 viewModel?.applyTrackerDeletion(deletedTracker)
94 if let viewModel {
95 Task { await viewModel.loadTrackers() }
96 }
97 self.createdTracker = nil
98 }
99 }
100 }
101 .alert("Delete Tracker?", isPresented: Binding(
102 get: { pendingDeletion != nil },
103 set: { isPresented in
104 if !isPresented {
105 pendingDeletion = nil
106 }
107 }
108 )) {
109 Button("Cancel", role: .cancel) {}
110 Button("Delete", role: .destructive) {
111 guard let pendingDeletion, let viewModel else { return }
112 Task {
113 let didDelete = await viewModel.deleteTracker(pendingDeletion)
114 if didDelete {
115 if createdTracker?.id == pendingDeletion.id {
116 createdTracker = nil
117 }
118 self.pendingDeletion = nil
119 }
120 }
121 }
122 } message: {
123 if let pendingDeletion {
124 Text("“\(pendingDeletion.name)” will be permanently deleted.")
52125 }
53126 }
54127 .task {
@@ -69,6 +142,20 @@ struct TrackerListView: View {
69142 NavigationLink(value: tracker) {
70143 TrackerRowView(tracker: tracker)
71144 }
145 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
146 Button(role: .destructive) {
147 pendingDeletion = tracker
148 } label: {
149 Label("Delete", systemImage: "trash")
150 }
151
152 Button {
153 editingTracker = tracker
154 } label: {
155 Label("Edit", systemImage: "pencil")
156 }
157 .tint(.blue)
158 }
72159 .task {
73160 await viewModel.loadMoreIfNeeded(currentItem: tracker)
74161 }
@@ -116,92 +203,16 @@ struct TrackerListView: View {
116203 await viewModel.loadTrackers()
117204 }
118205 .navigationDestination(for: TrackerSummary.self) { tracker in
119 TicketListView(
120 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
121 trackerName: tracker.name,
122 trackerId: tracker.id,
123 trackerRid: tracker.rid
124 )
125 }
126 }
127}
128
129private struct CreateTrackerSheet: View {
130 let viewModel: TrackerListViewModel
131 let onCreated: (TrackerSummary) -> Void
132
133 @Environment(\.dismiss) private var dismiss
134 @Bindable var viewModelBindable: TrackerListViewModel
135 @State private var name = ""
136 @State private var description = ""
137 @State private var visibility: Visibility = .public
138
139 init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
140 self.viewModel = viewModel
141 self._viewModelBindable = Bindable(viewModel)
142 self.onCreated = onCreated
143 }
144
145 var body: some View {
146 NavigationStack {
147 Form {
148 Section("Tracker Details") {
149 TextField("Tracker name", text: $name)
150 .textInputAutocapitalization(.never)
151 .autocorrectionDisabled()
152 TextField("Short description (optional)", text: $description, axis: .vertical)
153 .lineLimit(2...4)
154 Picker("Visibility", selection: $visibility) {
155 Text("Public").tag(Visibility.public)
156 Text("Unlisted").tag(Visibility.unlisted)
157 Text("Private").tag(Visibility.private)
158 }
206 TicketListView(tracker: tracker) { updatedTracker in
207 viewModel.applyTrackerUpdate(updatedTracker)
208 if createdTracker?.id == updatedTracker.id {
209 createdTracker = updatedTracker
159210 }
160
161 if let error = viewModel.error {
162 Section {
163 Label {
164 Text(error)
165 } icon: {
166 Image(systemName: "exclamationmark.triangle.fill")
167 .foregroundStyle(.red)
168 }
169 .foregroundStyle(.red)
170 }
171 }
172 }
173 .navigationTitle("New Tracker")
174 .navigationBarTitleDisplayMode(.inline)
175 .onDisappear {
176 viewModelBindable.error = nil
177 }
178 .toolbar {
179 ToolbarItem(placement: .cancellationAction) {
180 Button("Cancel") {
181 viewModelBindable.error = nil
182 dismiss()
183 }
184 }
185 ToolbarItem(placement: .confirmationAction) {
186 Button {
187 Task {
188 if let tracker = await viewModel.createTracker(
189 name: name,
190 description: description,
191 visibility: visibility
192 ) {
193 onCreated(tracker)
194 }
195 }
196 } label: {
197 if viewModel.isCreatingTracker {
198 ProgressView()
199 .controlSize(.small)
200 } else {
201 Text("Create Tracker")
202 }
203 }
204 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
211 } onTrackerDeleted: { deletedTracker in
212 viewModel.applyTrackerDeletion(deletedTracker)
213 Task { await viewModel.loadTrackers() }
214 if createdTracker?.id == deletedTracker.id {
215 createdTracker = nil
205216 }
206217 }
207218 }
Hutch/Views/Tickets/TrackerListViewModel.swift +112
@@ -11,6 +11,18 @@ private struct TrackersPage: Decodable, Sendable {
1111 let cursor: String?
1212}
1313
14private struct UpdateTrackerResponse: Decodable, Sendable {
15 let updateTracker: TrackerSummary
16}
17
18private struct DeleteTrackerResponse: Decodable, Sendable {
19 let deleteTracker: DeletedTracker
20}
21
22private struct DeletedTracker: Decodable, Sendable {
23 let id: Int
24}
25
1426// MARK: - View Model
1527
1628@Observable
@@ -75,6 +87,28 @@ final class TrackerListViewModel {
7587 }
7688 """
7789
90 private static let updateTrackerMutation = """
91 mutation updateTracker($id: Int!, $input: TrackerInput!) {
92 updateTracker(id: $id, input: $input) {
93 id
94 rid
95 name
96 description
97 visibility
98 updated
99 owner { canonicalName }
100 }
101 }
102 """
103
104 private static let deleteTrackerMutation = """
105 mutation deleteTracker($id: Int!) {
106 deleteTracker(id: $id) {
107 id
108 }
109 }
110 """
111
78112 // MARK: - Public API
79113
80114 func loadTrackers() async {
@@ -155,6 +189,84 @@ final class TrackerListViewModel {
155189 }
156190 }
157191
192 func updateTracker(
193 _ tracker: TrackerSummary,
194 name: String,
195 description: String,
196 visibility: Visibility
197 ) async -> TrackerSummary? {
198 guard !isCreatingTracker else { return nil }
199
200 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
201 guard !trimmedName.isEmpty else {
202 error = "Enter a tracker name."
203 return nil
204 }
205
206 isCreatingTracker = true
207 error = nil
208 defer { isCreatingTracker = false }
209
210 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
211 let input: [String: any Sendable] = [
212 "name": trimmedName,
213 "description": trimmedDescription.isEmpty ? "" : trimmedDescription,
214 "visibility": visibility.rawValue
215 ]
216
217 do {
218 let result = try await client.execute(
219 service: .todo,
220 query: Self.updateTrackerMutation,
221 variables: [
222 "id": tracker.id,
223 "input": input
224 ],
225 responseType: UpdateTrackerResponse.self
226 )
227 applyTrackerUpdate(result.updateTracker)
228 return result.updateTracker
229 } catch {
230 self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
231 return nil
232 }
233 }
234
235 func deleteTracker(_ tracker: TrackerSummary) async -> Bool {
236 guard !isCreatingTracker else { return false }
237
238 isCreatingTracker = true
239 error = nil
240 defer { isCreatingTracker = false }
241
242 do {
243 _ = try await client.execute(
244 service: .todo,
245 query: Self.deleteTrackerMutation,
246 variables: ["id": tracker.id],
247 responseType: DeleteTrackerResponse.self
248 )
249 trackers.removeAll { $0.id == tracker.id }
250 await loadTrackers()
251 return true
252 } catch {
253 self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
254 return false
255 }
256 }
257
258 func applyTrackerUpdate(_ tracker: TrackerSummary) {
259 if let index = trackers.firstIndex(where: { $0.id == tracker.id }) {
260 trackers[index] = tracker
261 } else {
262 trackers.insert(tracker, at: 0)
263 }
264 }
265
266 func applyTrackerDeletion(_ tracker: TrackerSummary) {
267 trackers.removeAll { $0.id == tracker.id }
268 }
269
158270 // MARK: - Private
159271
160272 private func fetchPage(cursor: String?) async throws -> TrackersPage {
Hutch/Views/Tickets/TrackerManagementView.swift added +1268
@@ -0,0 +1,1268 @@
1import SwiftUI
2
3private struct TrackerQueryResponse: Decodable, Sendable {
4 let tracker: TrackerSummary?
5}
6
7private struct TrackerACLQueryResponse: Decodable, Sendable {
8 let tracker: TrackerACLQueryPayload?
9}
10
11private struct TrackerACLQueryPayload: Decodable, Sendable {
12 let defaultACL: DefaultTrackerACL
13 let acls: TrackerACLPage
14}
15
16private struct TrackerACLPage: Decodable, Sendable {
17 let results: [TrackerACL]
18 let cursor: String?
19}
20
21private struct TrackerLabelQueryResponse: Decodable, Sendable {
22 let tracker: TrackerLabelQueryPayload?
23}
24
25private struct TrackerLabelQueryPayload: Decodable, Sendable {
26 let labels: TrackerLabelPage
27}
28
29private struct TrackerLabelPage: Decodable, Sendable {
30 let results: [TicketLabel]
31 let cursor: String?
32}
33
34private struct UpdateTrackerResponse: Decodable, Sendable {
35 let updateTracker: TrackerSummary
36}
37
38private struct DeleteTrackerResponse: Decodable, Sendable {
39 let deleteTracker: DeletedTracker
40}
41
42private struct DeletedTracker: Decodable, Sendable {
43 let id: Int
44}
45
46private struct UpdateUserACLResponse: Decodable, Sendable {
47 let updateUserACL: TrackerACL
48}
49
50private struct UpdateTrackerACLResponse: Decodable, Sendable {
51 let updateTrackerACL: DefaultTrackerACL
52}
53
54private struct DeleteTrackerACLResponse: Decodable, Sendable {
55 let deleteACL: TrackerACL
56}
57
58private struct CreateTrackerLabelResponse: Decodable, Sendable {
59 let createLabel: TicketLabel
60}
61
62private struct UpdateTrackerLabelResponse: Decodable, Sendable {
63 let updateLabel: TicketLabel
64}
65
66private struct DeleteTrackerLabelResponse: Decodable, Sendable {
67 let deleteLabel: TicketLabel
68}
69
70private struct TrackerUserLookupResponse: Decodable, Sendable {
71 let user: UserIdPayload?
72}
73
74private struct UserIdPayload: Decodable, Sendable {
75 let id: Int
76}
77
78@Observable
79@MainActor
80final class TrackerManagementViewModel {
81 private(set) var tracker: TrackerSummary
82 private(set) var acls: [TrackerACL] = []
83 private(set) var defaultACL = DefaultTrackerACL(
84 browse: true,
85 submit: true,
86 comment: true,
87 edit: false,
88 triage: false
89 )
90 private(set) var labels: [TicketLabel] = []
91
92 private(set) var isSavingTracker = false
93 private(set) var isDeletingTracker = false
94 private(set) var isLoadingACLs = false
95 private(set) var isSavingACL = false
96 private(set) var isDeletingACL = false
97 private(set) var isLoadingLabels = false
98 private(set) var isSavingLabel = false
99 private(set) var isDeletingLabel = false
100
101 var error: String?
102 var didDeleteTracker = false
103
104 private let client: SRHTClient
105
106 init(tracker: TrackerSummary, client: SRHTClient) {
107 self.tracker = tracker
108 self.client = client
109 }
110
111 private static let trackerQuery = """
112 query tracker($rid: ID!) {
113 tracker(rid: $rid) {
114 id
115 rid
116 name
117 description
118 visibility
119 updated
120 owner { canonicalName }
121 }
122 }
123 """
124
125 private static let trackerACLsQuery = """
126 query trackerACLs($rid: ID!, $cursor: Cursor) {
127 tracker(rid: $rid) {
128 defaultACL {
129 browse
130 submit
131 comment
132 edit
133 triage
134 }
135 acls(cursor: $cursor) {
136 results {
137 id
138 created
139 entity { canonicalName }
140 browse
141 submit
142 comment
143 edit
144 triage
145 }
146 cursor
147 }
148 }
149 }
150 """
151
152 private static let trackerLabelsQuery = """
153 query trackerLabels($rid: ID!, $cursor: Cursor) {
154 tracker(rid: $rid) {
155 labels(cursor: $cursor) {
156 results {
157 id
158 name
159 backgroundColor
160 foregroundColor
161 }
162 cursor
163 }
164 }
165 }
166 """
167
168 private static let updateTrackerMutation = """
169 mutation updateTracker($id: Int!, $input: TrackerInput!) {
170 updateTracker(id: $id, input: $input) {
171 id
172 rid
173 name
174 description
175 visibility
176 updated
177 owner { canonicalName }
178 }
179 }
180 """
181
182 private static let deleteTrackerMutation = """
183 mutation deleteTracker($id: Int!) {
184 deleteTracker(id: $id) {
185 id
186 }
187 }
188 """
189
190 private static let updateUserACLMutation = """
191 mutation updateUserACL($trackerId: Int!, $userId: Int!, $input: ACLInput!) {
192 updateUserACL(trackerId: $trackerId, userId: $userId, input: $input) {
193 id
194 created
195 entity { canonicalName }
196 browse
197 submit
198 comment
199 edit
200 triage
201 }
202 }
203 """
204
205 private static let updateTrackerACLMutation = """
206 mutation updateTrackerACL($trackerId: Int!, $input: ACLInput!) {
207 updateTrackerACL(trackerId: $trackerId, input: $input) {
208 browse
209 submit
210 comment
211 edit
212 triage
213 }
214 }
215 """
216
217 private static let deleteACLMutation = """
218 mutation deleteACL($id: Int!) {
219 deleteACL(id: $id) {
220 id
221 created
222 entity { canonicalName }
223 browse
224 submit
225 comment
226 edit
227 triage
228 }
229 }
230 """
231
232 private static let createLabelMutation = """
233 mutation createLabel($trackerId: Int!, $name: String!, $foregroundColor: String!, $backgroundColor: String!) {
234 createLabel(trackerId: $trackerId, name: $name, foregroundColor: $foregroundColor, backgroundColor: $backgroundColor) {
235 id
236 name
237 backgroundColor
238 foregroundColor
239 }
240 }
241 """
242
243 private static let updateLabelMutation = """
244 mutation updateLabel($id: Int!, $input: UpdateLabelInput!) {
245 updateLabel(id: $id, input: $input) {
246 id
247 name
248 backgroundColor
249 foregroundColor
250 }
251 }
252 """
253
254 private static let deleteLabelMutation = """
255 mutation deleteLabel($id: Int!) {
256 deleteLabel(id: $id) {
257 id
258 name
259 backgroundColor
260 foregroundColor
261 }
262 }
263 """
264
265 private static let userLookupQuery = """
266 query userLookup($username: String!) {
267 user(username: $username) {
268 id
269 }
270 }
271 """
272
273 func refreshTracker() async {
274 do {
275 let result = try await client.execute(
276 service: .todo,
277 query: Self.trackerQuery,
278 variables: ["rid": tracker.rid],
279 responseType: TrackerQueryResponse.self
280 )
281 if let tracker = result.tracker {
282 self.tracker = tracker
283 }
284 } catch {
285 self.error = error.userFacingMessage
286 }
287 }
288
289 func updateTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
290 guard !isSavingTracker else { return nil }
291 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
292 guard !trimmedName.isEmpty else {
293 error = "Enter a tracker name."
294 return nil
295 }
296
297 isSavingTracker = true
298 error = nil
299 defer { isSavingTracker = false }
300
301 var input: [String: any Sendable] = [
302 "name": trimmedName,
303 "visibility": visibility.rawValue
304 ]
305 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
306 input["description"] = trimmedDescription.isEmpty ? "" : trimmedDescription
307
308 do {
309 let result = try await client.execute(
310 service: .todo,
311 query: Self.updateTrackerMutation,
312 variables: [
313 "id": tracker.id,
314 "input": input
315 ],
316 responseType: UpdateTrackerResponse.self
317 )
318 tracker = result.updateTracker
319 return result.updateTracker
320 } catch {
321 self.error = "Couldn’t update the tracker. \(error.userFacingMessage)"
322 return nil
323 }
324 }
325
326 func deleteTracker() async -> Bool {
327 guard !isDeletingTracker else { return false }
328 isDeletingTracker = true
329 error = nil
330 defer { isDeletingTracker = false }
331
332 do {
333 _ = try await client.execute(
334 service: .todo,
335 query: Self.deleteTrackerMutation,
336 variables: ["id": tracker.id],
337 responseType: DeleteTrackerResponse.self
338 )
339 didDeleteTracker = true
340 return true
341 } catch {
342 self.error = "Couldn’t delete the tracker. \(error.userFacingMessage)"
343 return false
344 }
345 }
346
347 func loadACLs() async {
348 guard !isLoadingACLs else { return }
349 isLoadingACLs = true
350 error = nil
351 defer { isLoadingACLs = false }
352
353 do {
354 let result = try await client.execute(
355 service: .todo,
356 query: Self.trackerACLsQuery,
357 variables: ["rid": tracker.rid],
358 responseType: TrackerACLQueryResponse.self
359 )
360 defaultACL = result.tracker?.defaultACL ?? defaultACL
361 acls = result.tracker?.acls.results ?? []
362 } catch {
363 self.error = error.userFacingMessage
364 }
365 }
366
367 func updateDefaultACL(_ permissions: TrackerACLPermissions) async -> Bool {
368 guard !isSavingACL else { return false }
369 isSavingACL = true
370 error = nil
371 defer { isSavingACL = false }
372
373 do {
374 let result = try await client.execute(
375 service: .todo,
376 query: Self.updateTrackerACLMutation,
377 variables: [
378 "trackerId": tracker.id,
379 "input": permissions.graphQLInput
380 ],
381 responseType: UpdateTrackerACLResponse.self
382 )
383 defaultACL = result.updateTrackerACL
384 await loadACLs()
385 return true
386 } catch {
387 self.error = error.userFacingMessage
388 return false
389 }
390 }
391
392 func addOrUpdateACL(username: String, permissions: TrackerACLPermissions) async -> Bool {
393 guard !isSavingACL else { return false }
394 let normalizedUsername = Self.normalizedUsername(username)
395 guard !normalizedUsername.isEmpty else {
396 error = "Enter a SourceHut username."
397 return false
398 }
399
400 isSavingACL = true
401 error = nil
402 defer { isSavingACL = false }
403
404 do {
405 let userResult = try await client.execute(
406 service: .todo,
407 query: Self.userLookupQuery,
408 variables: ["username": normalizedUsername],
409 responseType: TrackerUserLookupResponse.self
410 )
411 guard let userId = userResult.user?.id else {
412 error = "That user couldn’t be found."
413 return false
414 }
415
416 let result = try await client.execute(
417 service: .todo,
418 query: Self.updateUserACLMutation,
419 variables: [
420 "trackerId": tracker.id,
421 "userId": userId,
422 "input": permissions.graphQLInput
423 ],
424 responseType: UpdateUserACLResponse.self
425 )
426 if let index = acls.firstIndex(where: { $0.id == result.updateUserACL.id }) {
427 acls[index] = result.updateUserACL
428 } else {
429 acls.append(result.updateUserACL)
430 acls.sort { $0.entity.canonicalName.localizedCaseInsensitiveCompare($1.entity.canonicalName) == .orderedAscending }
431 }
432 await loadACLs()
433 return true
434 } catch {
435 self.error = error.userFacingMessage
436 return false
437 }
438 }
439
440 func deleteACL(_ entry: TrackerACL) async {
441 guard !isDeletingACL else { return }
442 isDeletingACL = true
443 error = nil
444 defer { isDeletingACL = false }
445
446 do {
447 _ = try await client.execute(
448 service: .todo,
449 query: Self.deleteACLMutation,
450 variables: ["id": entry.id],
451 responseType: DeleteTrackerACLResponse.self
452 )
453 acls.removeAll { $0.id == entry.id }
454 await loadACLs()
455 } catch {
456 self.error = error.userFacingMessage
457 }
458 }
459
460 func loadLabels() async {
461 guard !isLoadingLabels else { return }
462 isLoadingLabels = true
463 error = nil
464 defer { isLoadingLabels = false }
465
466 do {
467 let result = try await client.execute(
468 service: .todo,
469 query: Self.trackerLabelsQuery,
470 variables: ["rid": tracker.rid],
471 responseType: TrackerLabelQueryResponse.self
472 )
473 labels = result.tracker?.labels.results ?? []
474 } catch {
475 self.error = error.userFacingMessage
476 }
477 }
478
479 func createLabel(name: String, foregroundColor: String, backgroundColor: String) async -> Bool {
480 guard !isSavingLabel else { return false }
481 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
482 guard !trimmedName.isEmpty else {
483 error = "Enter a label name."
484 return false
485 }
486 guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
487 error = "Label colors must use #RRGGBB format."
488 return false
489 }
490
491 isSavingLabel = true
492 error = nil
493 defer { isSavingLabel = false }
494
495 do {
496 _ = try await client.execute(
497 service: .todo,
498 query: Self.createLabelMutation,
499 variables: [
500 "trackerId": tracker.id,
501 "name": trimmedName,
502 "foregroundColor": foregroundColor,
503 "backgroundColor": backgroundColor
504 ],
505 responseType: CreateTrackerLabelResponse.self
506 )
507 await loadLabels()
508 return true
509 } catch {
510 self.error = error.userFacingMessage
511 return false
512 }
513 }
514
515 func updateLabel(
516 _ label: TicketLabel,
517 name: String,
518 foregroundColor: String,
519 backgroundColor: String
520 ) async -> Bool {
521 guard !isSavingLabel else { return false }
522 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
523 guard !trimmedName.isEmpty else {
524 error = "Enter a label name."
525 return false
526 }
527 guard Self.isValidHexColor(foregroundColor), Self.isValidHexColor(backgroundColor) else {
528 error = "Label colors must use #RRGGBB format."
529 return false
530 }
531
532 isSavingLabel = true
533 error = nil
534 defer { isSavingLabel = false }
535
536 var input: [String: any Sendable] = [:]
537 if trimmedName != label.name {
538 input["name"] = trimmedName
539 }
540 if foregroundColor.caseInsensitiveCompare(label.foregroundColor) != .orderedSame {
541 input["foregroundColor"] = foregroundColor
542 }
543 if backgroundColor.caseInsensitiveCompare(label.backgroundColor) != .orderedSame {
544 input["backgroundColor"] = backgroundColor
545 }
546
547 guard !input.isEmpty else { return true }
548
549 do {
550 _ = try await client.execute(
551 service: .todo,
552 query: Self.updateLabelMutation,
553 variables: [
554 "id": label.id,
555 "input": input
556 ],
557 responseType: UpdateTrackerLabelResponse.self
558 )
559 await loadLabels()
560 return true
561 } catch {
562 self.error = error.userFacingMessage
563 return false
564 }
565 }
566
567 func deleteLabel(_ label: TicketLabel) async {
568 guard !isDeletingLabel else { return }
569 isDeletingLabel = true
570 error = nil
571 defer { isDeletingLabel = false }
572
573 do {
574 _ = try await client.execute(
575 service: .todo,
576 query: Self.deleteLabelMutation,
577 variables: ["id": label.id],
578 responseType: DeleteTrackerLabelResponse.self
579 )
580 labels.removeAll { $0.id == label.id }
581 await loadLabels()
582 } catch {
583 self.error = error.userFacingMessage
584 }
585 }
586
587 static func normalizedUsername(_ value: String) -> String {
588 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
589 guard !trimmed.isEmpty else { return "" }
590 return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
591 }
592
593 static func isValidHexColor(_ value: String) -> Bool {
594 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
595 guard trimmed.count == 7, trimmed.first == "#" else { return false }
596 return trimmed.dropFirst().allSatisfy { $0.isHexDigit }
597 }
598}
599
600private extension TrackerACLPermissions {
601 var graphQLInput: [String: any Sendable] {
602 [
603 "browse": browse,
604 "submit": submit,
605 "comment": comment,
606 "edit": edit,
607 "triage": triage
608 ]
609 }
610}
611
612struct TrackerEditorSheet: View {
613 let title: String
614 let confirmationTitle: String
615 let isSaving: Bool
616 let error: String?
617 let initialName: String
618 let initialDescription: String
619 let initialVisibility: Visibility
620 let onSave: (String, String, Visibility) async -> Bool
621
622 @Environment(\.dismiss) private var dismiss
623 @State private var name: String
624 @State private var description: String
625 @State private var visibility: Visibility
626
627 init(
628 title: String,
629 confirmationTitle: String,
630 isSaving: Bool,
631 error: String?,
632 initialName: String,
633 initialDescription: String,
634 initialVisibility: Visibility,
635 onSave: @escaping (String, String, Visibility) async -> Bool
636 ) {
637 self.title = title
638 self.confirmationTitle = confirmationTitle
639 self.isSaving = isSaving
640 self.error = error
641 self.initialName = initialName
642 self.initialDescription = initialDescription
643 self.initialVisibility = initialVisibility
644 self.onSave = onSave
645 _name = State(initialValue: initialName)
646 _description = State(initialValue: initialDescription)
647 _visibility = State(initialValue: initialVisibility)
648 }
649
650 var body: some View {
651 NavigationStack {
652 Form {
653 Section("Tracker Details") {
654 TextField("Tracker name", text: $name)
655 .textInputAutocapitalization(.never)
656 .autocorrectionDisabled()
657 TextField("Short description (optional)", text: $description, axis: .vertical)
658 .lineLimit(2...4)
659 Picker("Visibility", selection: $visibility) {
660 Text("Public").tag(Visibility.public)
661 Text("Unlisted").tag(Visibility.unlisted)
662 Text("Private").tag(Visibility.private)
663 }
664 }
665
666 if let error, !error.isEmpty {
667 Section {
668 Text(error)
669 .foregroundStyle(.red)
670 }
671 }
672 }
673 .navigationTitle(title)
674 .navigationBarTitleDisplayMode(.inline)
675 .toolbar {
676 ToolbarItem(placement: .cancellationAction) {
677 Button("Cancel") { dismiss() }
678 }
679 ToolbarItem(placement: .confirmationAction) {
680 Button {
681 Task {
682 let didSave = await onSave(name, description, visibility)
683 if didSave {
684 dismiss()
685 }
686 }
687 } label: {
688 if isSaving {
689 ProgressView()
690 .controlSize(.small)
691 } else {
692 Text(confirmationTitle)
693 }
694 }
695 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
696 }
697 }
698 }
699 }
700}
701
702struct TrackerACLManagementSheet: View {
703 let viewModel: TrackerManagementViewModel
704
705 @Bindable private var bindableViewModel: TrackerManagementViewModel
706 @State private var editingACL: TrackerACL?
707 @State private var editingDefaultACL = false
708 @State private var pendingDeletion: TrackerACL?
709 @State private var showCreateACL = false
710
711 init(viewModel: TrackerManagementViewModel) {
712 self.viewModel = viewModel
713 self._bindableViewModel = Bindable(viewModel)
714 }
715
716 var body: some View {
717 NavigationStack {
718 List {
719 Section("Default Access") {
720 TrackerPermissionSummary(permissions: viewModel.defaultACL.permissions)
721 Button("Update Default ACL") {
722 editingDefaultACL = true
723 }
724 .disabled(viewModel.isSavingACL)
725 }
726
727 Section {
728 if viewModel.isLoadingACLs {
729 HStack {
730 Spacer()
731 ProgressView()
732 Spacer()
733 }
734 } else if viewModel.acls.isEmpty {
735 Text("No tracker-specific ACLs yet.")
736 .foregroundStyle(.secondary)
737 } else {
738 ForEach(viewModel.acls) { entry in
739 VStack(alignment: .leading, spacing: 6) {
740 Text(entry.entity.canonicalName)
741 .font(.subheadline.weight(.medium))
742 TrackerPermissionSummary(permissions: entry.permissions)
743 }
744 .swipeActions(edge: .leading, allowsFullSwipe: false) {
745 Button {
746 editingACL = entry
747 } label: {
748 Label("Edit", systemImage: "pencil")
749 }
750 .tint(.blue)
751 }
752 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
753 Button(role: .destructive) {
754 pendingDeletion = entry
755 } label: {
756 Label("Delete", systemImage: "trash")
757 }
758 }
759 }
760 }
761 } header: {
762 Text("User ACLs")
763 } footer: {
764 Text("Each ACL must include all five permission flags.")
765 }
766 }
767 .navigationTitle("ACLs")
768 .navigationBarTitleDisplayMode(.inline)
769 .toolbar {
770 ToolbarItem(placement: .topBarTrailing) {
771 Button {
772 showCreateACL = true
773 } label: {
774 Image(systemName: "plus")
775 }
776 .disabled(viewModel.isSavingACL)
777 }
778 }
779 .task {
780 await viewModel.loadACLs()
781 }
782 .srhtErrorBanner(error: $bindableViewModel.error)
783 .sheet(isPresented: $showCreateACL) {
784 TrackerACLEditorSheet(
785 title: "Add ACL",
786 submitTitle: "Save",
787 isSaving: viewModel.isSavingACL,
788 error: viewModel.error,
789 initialUsername: "",
790 initialPermissions: viewModel.defaultACL.permissions
791 ) { username, permissions in
792 await viewModel.addOrUpdateACL(username: username, permissions: permissions)
793 }
794 }
795 .sheet(item: $editingACL) { entry in
796 TrackerACLEditorSheet(
797 title: "Update ACL",
798 submitTitle: "Save",
799 isSaving: viewModel.isSavingACL,
800 error: viewModel.error,
801 initialUsername: entry.entity.canonicalName,
802 initialPermissions: entry.permissions
803 ) { username, permissions in
804 await viewModel.addOrUpdateACL(username: username, permissions: permissions)
805 }
806 }
807 .sheet(isPresented: $editingDefaultACL) {
808 TrackerDefaultACLEditorSheet(
809 isSaving: viewModel.isSavingACL,
810 error: viewModel.error,
811 initialPermissions: viewModel.defaultACL.permissions
812 ) { permissions in
813 await viewModel.updateDefaultACL(permissions)
814 }
815 }
816 .alert("Remove Access?", isPresented: Binding(
817 get: { pendingDeletion != nil },
818 set: { isPresented in
819 if !isPresented {
820 pendingDeletion = nil
821 }
822 }
823 )) {
824 Button("Cancel", role: .cancel) {}
825 Button("Delete", role: .destructive) {
826 guard let pendingDeletion else { return }
827 Task {
828 await viewModel.deleteACL(pendingDeletion)
829 self.pendingDeletion = nil
830 }
831 }
832 } message: {
833 if let pendingDeletion {
834 Text("\(pendingDeletion.entity.canonicalName) will fall back to the tracker default ACL.")
835 }
836 }
837 }
838 }
839}
840
841private struct TrackerPermissionSummary: View {
842 let permissions: TrackerACLPermissions
843
844 var body: some View {
845 Text(summary)
846 .font(.caption)
847 .foregroundStyle(.secondary)
848 }
849
850 private var summary: String {
851 let items = [
852 permissions.browse ? "browse" : nil,
853 permissions.submit ? "submit" : nil,
854 permissions.comment ? "comment" : nil,
855 permissions.edit ? "edit" : nil,
856 permissions.triage ? "triage" : nil
857 ].compactMap { $0 }
858 return items.isEmpty ? "No permissions" : items.joined(separator: ", ")
859 }
860}
861
862private struct TrackerACLEditorSheet: View {
863 let title: String
864 let submitTitle: String
865 let isSaving: Bool
866 let error: String?
867 let initialUsername: String
868 let initialPermissions: TrackerACLPermissions
869 let onSave: (String, TrackerACLPermissions) async -> Bool
870
871 @Environment(\.dismiss) private var dismiss
872 @State private var username: String
873 @State private var browse: Bool
874 @State private var submit: Bool
875 @State private var comment: Bool
876 @State private var edit: Bool
877 @State private var triage: Bool
878
879 init(
880 title: String,
881 submitTitle: String,
882 isSaving: Bool,
883 error: String?,
884 initialUsername: String,
885 initialPermissions: TrackerACLPermissions,
886 onSave: @escaping (String, TrackerACLPermissions) async -> Bool
887 ) {
888 self.title = title
889 self.submitTitle = submitTitle
890 self.isSaving = isSaving
891 self.error = error
892 self.initialUsername = initialUsername
893 self.initialPermissions = initialPermissions
894 self.onSave = onSave
895 _username = State(initialValue: initialUsername)
896 _browse = State(initialValue: initialPermissions.browse)
897 _submit = State(initialValue: initialPermissions.submit)
898 _comment = State(initialValue: initialPermissions.comment)
899 _edit = State(initialValue: initialPermissions.edit)
900 _triage = State(initialValue: initialPermissions.triage)
901 }
902
903 var body: some View {
904 NavigationStack {
905 Form {
906 Section("User") {
907 TextField("Username or ~username", text: $username)
908 .autocorrectionDisabled()
909 .textInputAutocapitalization(.never)
910 }
911
912 permissionSection
913
914 if let error, !error.isEmpty {
915 Section {
916 Text(error)
917 .foregroundStyle(.red)
918 }
919 }
920 }
921 .navigationTitle(title)
922 .navigationBarTitleDisplayMode(.inline)
923 .toolbar {
924 ToolbarItem(placement: .cancellationAction) {
925 Button("Cancel") { dismiss() }
926 }
927 ToolbarItem(placement: .confirmationAction) {
928 Button {
929 Task {
930 let didSave = await onSave(username, permissions)
931 if didSave {
932 dismiss()
933 }
934 }
935 } label: {
936 if isSaving {
937 ProgressView()
938 .controlSize(.small)
939 } else {
940 Text(submitTitle)
941 }
942 }
943 .disabled(username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
944 }
945 }
946 }
947 }
948
949 private var permissionSection: some View {
950 Section("Permissions") {
951 Toggle("Browse", isOn: $browse)
952 Toggle("Submit", isOn: $submit)
953 Toggle("Comment", isOn: $comment)
954 Toggle("Edit", isOn: $edit)
955 Toggle("Triage", isOn: $triage)
956 }
957 }
958
959 private var permissions: TrackerACLPermissions {
960 TrackerACLPermissions(
961 browse: browse,
962 submit: submit,
963 comment: comment,
964 edit: edit,
965 triage: triage
966 )
967 }
968}
969
970private struct TrackerDefaultACLEditorSheet: View {
971 let isSaving: Bool
972 let error: String?
973 let initialPermissions: TrackerACLPermissions
974 let onSave: (TrackerACLPermissions) async -> Bool
975
976 @Environment(\.dismiss) private var dismiss
977 @State private var browse: Bool
978 @State private var submit: Bool
979 @State private var comment: Bool
980 @State private var edit: Bool
981 @State private var triage: Bool
982
983 init(
984 isSaving: Bool,
985 error: String?,
986 initialPermissions: TrackerACLPermissions,
987 onSave: @escaping (TrackerACLPermissions) async -> Bool
988 ) {
989 self.isSaving = isSaving
990 self.error = error
991 self.initialPermissions = initialPermissions
992 self.onSave = onSave
993 _browse = State(initialValue: initialPermissions.browse)
994 _submit = State(initialValue: initialPermissions.submit)
995 _comment = State(initialValue: initialPermissions.comment)
996 _edit = State(initialValue: initialPermissions.edit)
997 _triage = State(initialValue: initialPermissions.triage)
998 }
999
1000 var body: some View {
1001 NavigationStack {
1002 Form {
1003 Section("Permissions") {
1004 Toggle("Browse", isOn: $browse)
1005 Toggle("Submit", isOn: $submit)
1006 Toggle("Comment", isOn: $comment)
1007 Toggle("Edit", isOn: $edit)
1008 Toggle("Triage", isOn: $triage)
1009 }
1010
1011 if let error, !error.isEmpty {
1012 Section {
1013 Text(error)
1014 .foregroundStyle(.red)
1015 }
1016 }
1017 }
1018 .navigationTitle("Default ACL")
1019 .navigationBarTitleDisplayMode(.inline)
1020 .toolbar {
1021 ToolbarItem(placement: .cancellationAction) {
1022 Button("Cancel") { dismiss() }
1023 }
1024 ToolbarItem(placement: .confirmationAction) {
1025 Button {
1026 Task {
1027 let didSave = await onSave(
1028 TrackerACLPermissions(
1029 browse: browse,
1030 submit: submit,
1031 comment: comment,
1032 edit: edit,
1033 triage: triage
1034 )
1035 )
1036 if didSave {
1037 dismiss()
1038 }
1039 }
1040 } label: {
1041 if isSaving {
1042 ProgressView()
1043 .controlSize(.small)
1044 } else {
1045 Text("Save")
1046 }
1047 }
1048 .disabled(isSaving)
1049 }
1050 }
1051 }
1052 }
1053}
1054
1055struct TrackerLabelManagementSheet: View {
1056 let viewModel: TrackerManagementViewModel
1057
1058 @Bindable private var bindableViewModel: TrackerManagementViewModel
1059 @State private var showCreateLabel = false
1060 @State private var editingLabel: TicketLabel?
1061 @State private var pendingDeletion: TicketLabel?
1062
1063 init(viewModel: TrackerManagementViewModel) {
1064 self.viewModel = viewModel
1065 self._bindableViewModel = Bindable(viewModel)
1066 }
1067
1068 var body: some View {
1069 NavigationStack {
1070 List {
1071 if viewModel.isLoadingLabels {
1072 HStack {
1073 Spacer()
1074 ProgressView()
1075 Spacer()
1076 }
1077 } else if viewModel.labels.isEmpty {
1078 ContentUnavailableView(
1079 "No Labels",
1080 systemImage: "tag",
1081 description: Text("Create labels for triage and organization.")
1082 )
1083 } else {
1084 ForEach(viewModel.labels) { label in
1085 HStack {
1086 LabelPill(label: label)
1087 Spacer()
1088 }
1089 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
1090 Button {
1091 editingLabel = label
1092 } label: {
1093 Label("Edit", systemImage: "pencil")
1094 }
1095 .tint(.blue)
1096
1097 Button(role: .destructive) {
1098 pendingDeletion = label
1099 } label: {
1100 Label("Delete", systemImage: "trash")
1101 }
1102 }
1103 }
1104 }
1105 }
1106 .navigationTitle("Labels")
1107 .navigationBarTitleDisplayMode(.inline)
1108 .toolbar {
1109 ToolbarItem(placement: .topBarTrailing) {
1110 Button {
1111 showCreateLabel = true
1112 } label: {
1113 Image(systemName: "plus")
1114 }
1115 .disabled(viewModel.isSavingLabel)
1116 }
1117 }
1118 .task {
1119 await viewModel.loadLabels()
1120 }
1121 .srhtErrorBanner(error: $bindableViewModel.error)
1122 .sheet(isPresented: $showCreateLabel) {
1123 TrackerLabelEditorSheet(
1124 title: "New Label",
1125 submitTitle: "Create",
1126 isSaving: viewModel.isSavingLabel,
1127 error: viewModel.error,
1128 initialLabel: nil
1129 ) { name, foreground, background in
1130 await viewModel.createLabel(
1131 name: name,
1132 foregroundColor: foreground,
1133 backgroundColor: background
1134 )
1135 }
1136 }
1137 .sheet(item: $editingLabel) { label in
1138 TrackerLabelEditorSheet(
1139 title: "Update Label",
1140 submitTitle: "Save",
1141 isSaving: viewModel.isSavingLabel,
1142 error: viewModel.error,
1143 initialLabel: label
1144 ) { name, foreground, background in
1145 await viewModel.updateLabel(
1146 label,
1147 name: name,
1148 foregroundColor: foreground,
1149 backgroundColor: background
1150 )
1151 }
1152 }
1153 .alert("Delete Label?", isPresented: Binding(
1154 get: { pendingDeletion != nil },
1155 set: { isPresented in
1156 if !isPresented {
1157 pendingDeletion = nil
1158 }
1159 }
1160 )) {
1161 Button("Cancel", role: .cancel) {}
1162 Button("Delete", role: .destructive) {
1163 guard let pendingDeletion else { return }
1164 Task {
1165 await viewModel.deleteLabel(pendingDeletion)
1166 self.pendingDeletion = nil
1167 }
1168 }
1169 } message: {
1170 if let pendingDeletion {
1171 Text("“\(pendingDeletion.name)” will be removed from this tracker and from any tickets using it.")
1172 }
1173 }
1174 }
1175 }
1176}
1177
1178private struct TrackerLabelEditorSheet: View {
1179 let title: String
1180 let submitTitle: String
1181 let isSaving: Bool
1182 let error: String?
1183 let initialLabel: TicketLabel?
1184 let onSave: (String, String, String) async -> Bool
1185
1186 @Environment(\.dismiss) private var dismiss
1187 @State private var name: String
1188 @State private var foregroundColor: Color
1189 @State private var backgroundColor: Color
1190
1191 init(
1192 title: String,
1193 submitTitle: String,
1194 isSaving: Bool,
1195 error: String?,
1196 initialLabel: TicketLabel?,
1197 onSave: @escaping (String, String, String) async -> Bool
1198 ) {
1199 self.title = title
1200 self.submitTitle = submitTitle
1201 self.isSaving = isSaving
1202 self.error = error
1203 self.initialLabel = initialLabel
1204 self.onSave = onSave
1205 _name = State(initialValue: initialLabel?.name ?? "")
1206 _foregroundColor = State(initialValue: Color(hex: initialLabel?.foregroundColor ?? "#ffffff") ?? .white)
1207 _backgroundColor = State(initialValue: Color(hex: initialLabel?.backgroundColor ?? "#000000") ?? .black)
1208 }
1209
1210 var body: some View {
1211 NavigationStack {
1212 Form {
1213 Section("Details") {
1214 TextField("Label name", text: $name)
1215 ColorPicker("Foreground", selection: $foregroundColor, supportsOpacity: false)
1216 ColorPicker("Background", selection: $backgroundColor, supportsOpacity: false)
1217 }
1218
1219 Section("Preview") {
1220 LabelPill(
1221 label: TicketLabel(
1222 id: initialLabel?.id ?? -1,
1223 name: name.isEmpty ? "Preview" : name,
1224 backgroundColor: backgroundColor.hexString,
1225 foregroundColor: foregroundColor.hexString
1226 )
1227 )
1228 }
1229
1230 if let error, !error.isEmpty {
1231 Section {
1232 Text(error)
1233 .foregroundStyle(.red)
1234 }
1235 }
1236 }
1237 .navigationTitle(title)
1238 .navigationBarTitleDisplayMode(.inline)
1239 .toolbar {
1240 ToolbarItem(placement: .cancellationAction) {
1241 Button("Cancel") { dismiss() }
1242 }
1243 ToolbarItem(placement: .confirmationAction) {
1244 Button {
1245 Task {
1246 let didSave = await onSave(
1247 name,
1248 foregroundColor.hexString,
1249 backgroundColor.hexString
1250 )
1251 if didSave {
1252 dismiss()
1253 }
1254 }
1255 } label: {
1256 if isSaving {
1257 ProgressView()
1258 .controlSize(.small)
1259 } else {
1260 Text(submitTitle)
1261 }
1262 }
1263 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSaving)
1264 }
1265 }
1266 }
1267 }
1268}
HutchTests/TrackerManagementViewModelTests.swift added +36
@@ -0,0 +1,36 @@
1import Testing
2@testable import Hutch
3
4struct TrackerManagementViewModelTests {
5
6 @Test
7 func normalizedUsernameStripsLeadingTildeAndWhitespace() {
8 #expect(TrackerManagementViewModel.normalizedUsername(" ~alice ") == "alice")
9 #expect(TrackerManagementViewModel.normalizedUsername("bob") == "bob")
10 }
11
12 @Test
13 func hexColorValidationRequiresPoundAndSixHexDigits() {
14 #expect(TrackerManagementViewModel.isValidHexColor("#a1B2c3"))
15 #expect(!TrackerManagementViewModel.isValidHexColor("a1B2c3"))
16 #expect(!TrackerManagementViewModel.isValidHexColor("#12345"))
17 #expect(!TrackerManagementViewModel.isValidHexColor("#12GG45"))
18 }
19
20 @Test
21 func trackerACLPermissionsExposeBooleanFlags() {
22 let permissions = TrackerACLPermissions(
23 browse: true,
24 submit: false,
25 comment: true,
26 edit: false,
27 triage: true
28 )
29
30 #expect(permissions.browse)
31 #expect(!permissions.submit)
32 #expect(permissions.comment)
33 #expect(!permissions.edit)
34 #expect(permissions.triage)
35 }
36}