Commit f3a053dfa5
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -495,7 +495,7 @@ | |||
| 495 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 495 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 496 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 496 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 497 | CODE_SIGN_STYLE = Automatic; | 497 | CODE_SIGN_STYLE = Automatic; |
| 498 | CURRENT_PROJECT_VERSION = 16; | 498 | CURRENT_PROJECT_VERSION = 17; |
| 499 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 499 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 500 | ENABLE_PREVIEWS = YES; | 500 | ENABLE_PREVIEWS = YES; |
| 501 | GENERATE_INFOPLIST_FILE = YES; | 501 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -512,7 +512,7 @@ | |||
| 512 | "$(inherited)", | 512 | "$(inherited)", |
| 513 | "@executable_path/Frameworks", | 513 | "@executable_path/Frameworks", |
| 514 | ); | 514 | ); |
| 515 | MARKETING_VERSION = 2.6.1; | 515 | MARKETING_VERSION = 2.7.0; |
| 516 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 516 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 517 | PRODUCT_NAME = "$(TARGET_NAME)"; | 517 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 518 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 518 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -532,7 +532,7 @@ | |||
| 532 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | 532 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 533 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; | 533 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 534 | CODE_SIGN_STYLE = Automatic; | 534 | CODE_SIGN_STYLE = Automatic; |
| 535 | CURRENT_PROJECT_VERSION = 16; | 535 | CURRENT_PROJECT_VERSION = 17; |
| 536 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 536 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 537 | ENABLE_PREVIEWS = YES; | 537 | ENABLE_PREVIEWS = YES; |
| 538 | GENERATE_INFOPLIST_FILE = YES; | 538 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -549,7 +549,7 @@ | |||
| 549 | "$(inherited)", | 549 | "$(inherited)", |
| 550 | "@executable_path/Frameworks", | 550 | "@executable_path/Frameworks", |
| 551 | ); | 551 | ); |
| 552 | MARKETING_VERSION = 2.6.1; | 552 | MARKETING_VERSION = 2.7.0; |
| 553 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | 553 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 554 | PRODUCT_NAME = "$(TARGET_NAME)"; | 554 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 555 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | 555 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -612,7 +612,7 @@ | |||
| 612 | APPLICATION_EXTENSION_API_ONLY = YES; | 612 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 613 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 613 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 614 | CODE_SIGN_STYLE = Automatic; | 614 | CODE_SIGN_STYLE = Automatic; |
| 615 | CURRENT_PROJECT_VERSION = 16; | 615 | CURRENT_PROJECT_VERSION = 17; |
| 616 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 616 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 617 | GENERATE_INFOPLIST_FILE = NO; | 617 | GENERATE_INFOPLIST_FILE = NO; |
| 618 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 618 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -622,7 +622,7 @@ | |||
| 622 | "@executable_path/Frameworks", | 622 | "@executable_path/Frameworks", |
| 623 | "@executable_path/../../Frameworks", | 623 | "@executable_path/../../Frameworks", |
| 624 | ); | 624 | ); |
| 625 | MARKETING_VERSION = 2.6.1; | 625 | MARKETING_VERSION = 2.7.0; |
| 626 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 626 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 627 | PRODUCT_NAME = "$(TARGET_NAME)"; | 627 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 628 | SKIP_INSTALL = YES; | 628 | SKIP_INSTALL = YES; |
| @@ -641,7 +641,7 @@ | |||
| 641 | APPLICATION_EXTENSION_API_ONLY = YES; | 641 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 642 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; | 642 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 643 | CODE_SIGN_STYLE = Automatic; | 643 | CODE_SIGN_STYLE = Automatic; |
| 644 | CURRENT_PROJECT_VERSION = 16; | 644 | CURRENT_PROJECT_VERSION = 17; |
| 645 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | 645 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 646 | GENERATE_INFOPLIST_FILE = NO; | 646 | GENERATE_INFOPLIST_FILE = NO; |
| 647 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; | 647 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -651,7 +651,7 @@ | |||
| 651 | "@executable_path/Frameworks", | 651 | "@executable_path/Frameworks", |
| 652 | "@executable_path/../../Frameworks", | 652 | "@executable_path/../../Frameworks", |
| 653 | ); | 653 | ); |
| 654 | MARKETING_VERSION = 2.6.1; | 654 | MARKETING_VERSION = 2.7.0; |
| 655 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; | 655 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 656 | PRODUCT_NAME = "$(TARGET_NAME)"; | 656 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 657 | SKIP_INSTALL = YES; | 657 | SKIP_INSTALL = YES; |
Hutch/Models/Todo.swift +49
| @@ -81,6 +81,55 @@ struct TicketLabel: Codable, Sendable, Identifiable, Hashable { | |||
| 81 | let foregroundColor: String | 81 | let foregroundColor: String |
| 82 | } | 82 | } |
| 83 | 83 | ||
| 84 | // MARK: - Tracker ACL | ||
| 85 | |||
| 86 | struct 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 | |||
| 94 | struct 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 | |||
| 115 | struct 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 | |||
| 84 | // MARK: - Tracker | 133 | // MARK: - Tracker |
| 85 | 134 | ||
| 86 | /// A bug tracker from todo.sr.ht. | 135 | /// A bug tracker from todo.sr.ht. |
Hutch/Views/Tickets/TicketDetailViewModel.swift +54 −70
| @@ -3,10 +3,6 @@ import Foundation | |||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) |
| 4 | 4 | ||
| 5 | private struct TicketDetailResponse: Decodable, Sendable { | 5 | private struct TicketDetailResponse: Decodable, Sendable { |
| 6 | let user: UserTrackerTicketWrapper | ||
| 7 | } | ||
| 8 | |||
| 9 | private struct UserTrackerTicketWrapper: Decodable, Sendable { | ||
| 10 | let tracker: TrackerTicketWrapper | 6 | let tracker: TrackerTicketWrapper |
| 11 | } | 7 | } |
| 12 | 8 | ||
| @@ -85,10 +81,6 @@ private struct CreateLabelResponse: Decodable, Sendable { | |||
| 85 | } | 81 | } |
| 86 | 82 | ||
| 87 | private struct TrackerLabelsResponse: Decodable, Sendable { | 83 | private struct TrackerLabelsResponse: Decodable, Sendable { |
| 88 | let user: UserTrackerLabelsWrapper | ||
| 89 | } | ||
| 90 | |||
| 91 | private struct UserTrackerLabelsWrapper: Decodable, Sendable { | ||
| 92 | let tracker: TrackerLabelsWrapper | 84 | let tracker: TrackerLabelsWrapper |
| 93 | } | 85 | } |
| 94 | 86 | ||
| @@ -155,57 +147,55 @@ final class TicketDetailViewModel { | |||
| 155 | // MARK: - Queries | 147 | // MARK: - Queries |
| 156 | 148 | ||
| 157 | private static let detailQuery = """ | 149 | private static let detailQuery = """ |
| 158 | query ticket($owner: String!, $tracker: String!, $ticketId: Int!) { | 150 | query ticket($rid: ID!, $ticketId: Int!) { |
| 159 | user(username: $owner) { | 151 | tracker(rid: $rid) { |
| 160 | tracker(name: $tracker) { | 152 | ticket(id: $ticketId) { |
| 161 | ticket(id: $ticketId) { | 153 | id |
| 162 | id | 154 | created |
| 163 | created | 155 | updated |
| 164 | updated | 156 | title: subject |
| 165 | title: subject | 157 | description: body |
| 166 | description: body | 158 | status |
| 167 | status | 159 | resolution |
| 168 | resolution | 160 | authenticity |
| 169 | authenticity | 161 | submitter { canonicalName } |
| 170 | submitter { canonicalName } | 162 | assignees { canonicalName } |
| 171 | assignees { canonicalName } | 163 | labels { id name backgroundColor foregroundColor } |
| 172 | labels { id name backgroundColor foregroundColor } | 164 | events { |
| 173 | events { | 165 | results { |
| 174 | results { | 166 | id |
| 175 | id | 167 | created |
| 176 | created | 168 | changes { |
| 177 | changes { | 169 | eventType |
| 178 | eventType | 170 | ... on Comment { |
| 179 | ... on Comment { | 171 | author { canonicalName } |
| 180 | author { canonicalName } | 172 | text |
| 181 | text | 173 | authenticity |
| 182 | authenticity | 174 | } |
| 183 | } | 175 | ... on StatusChange { |
| 184 | ... on StatusChange { | 176 | oldStatus |
| 185 | oldStatus | 177 | newStatus |
| 186 | newStatus | 178 | } |
| 187 | } | 179 | ... on LabelUpdate { |
| 188 | ... on LabelUpdate { | 180 | labeler { canonicalName } |
| 189 | labeler { canonicalName } | 181 | label { name } |
| 190 | label { name } | 182 | } |
| 191 | } | 183 | ... on Assignment { |
| 192 | ... on Assignment { | 184 | assigner { canonicalName } |
| 193 | assigner { canonicalName } | 185 | assignee { canonicalName } |
| 194 | assignee { canonicalName } | 186 | } |
| 195 | } | 187 | ... on TicketMention { |
| 196 | ... on TicketMention { | 188 | mentioned { id } |
| 197 | mentioned { id } | 189 | } |
| 198 | } | 190 | ... on UserMention { |
| 199 | ... on UserMention { | 191 | mentioned { canonicalName } |
| 200 | mentioned { canonicalName } | 192 | } |
| 201 | } | 193 | ... on Created { |
| 202 | ... on Created { | 194 | author { canonicalName } |
| 203 | author { canonicalName } | ||
| 204 | } | ||
| 205 | } | 195 | } |
| 206 | } | 196 | } |
| 207 | cursor | ||
| 208 | } | 197 | } |
| 198 | cursor | ||
| 209 | } | 199 | } |
| 210 | } | 200 | } |
| 211 | } | 201 | } |
| @@ -268,12 +258,10 @@ final class TicketDetailViewModel { | |||
| 268 | """ | 258 | """ |
| 269 | 259 | ||
| 270 | private static let trackerLabelsQuery = """ | 260 | private static let trackerLabelsQuery = """ |
| 271 | query trackerLabels($owner: String!, $tracker: String!) { | 261 | query trackerLabels($rid: ID!) { |
| 272 | user(username: $owner) { | 262 | tracker(rid: $rid) { |
| 273 | tracker(name: $tracker) { | 263 | labels { |
| 274 | labels { | 264 | results { id name backgroundColor foregroundColor } |
| 275 | results { id name backgroundColor foregroundColor } | ||
| 276 | } | ||
| 277 | } | 265 | } |
| 278 | } | 266 | } |
| 279 | } | 267 | } |
| @@ -302,13 +290,12 @@ final class TicketDetailViewModel { | |||
| 302 | service: .todo, | 290 | service: .todo, |
| 303 | query: Self.detailQuery, | 291 | query: Self.detailQuery, |
| 304 | variables: [ | 292 | variables: [ |
| 305 | "owner": ownerUsername, | 293 | "rid": trackerRid, |
| 306 | "tracker": trackerName, | ||
| 307 | "ticketId": ticketId | 294 | "ticketId": ticketId |
| 308 | ], | 295 | ], |
| 309 | responseType: TicketDetailResponse.self | 296 | responseType: TicketDetailResponse.self |
| 310 | ) | 297 | ) |
| 311 | let payload = result.user.tracker.ticket | 298 | let payload = result.tracker.ticket |
| 312 | ticket = TicketDetail( | 299 | ticket = TicketDetail( |
| 313 | id: payload.id, | 300 | id: payload.id, |
| 314 | created: payload.created, | 301 | created: payload.created, |
| @@ -579,13 +566,10 @@ final class TicketDetailViewModel { | |||
| 579 | let result = try await client.execute( | 566 | let result = try await client.execute( |
| 580 | service: .todo, | 567 | service: .todo, |
| 581 | query: Self.trackerLabelsQuery, | 568 | query: Self.trackerLabelsQuery, |
| 582 | variables: [ | 569 | variables: ["rid": trackerRid], |
| 583 | "owner": ownerUsername, | ||
| 584 | "tracker": trackerName | ||
| 585 | ], | ||
| 586 | responseType: TrackerLabelsResponse.self | 570 | responseType: TrackerLabelsResponse.self |
| 587 | ) | 571 | ) |
| 588 | trackerLabels = result.user.tracker.labels.results | 572 | trackerLabels = result.tracker.labels.results |
| 589 | } catch { | 573 | } catch { |
| 590 | self.error = error.userFacingMessage | 574 | self.error = error.userFacingMessage |
| 591 | } | 575 | } |
Hutch/Views/Tickets/TicketListView.swift +130 −12
| @@ -1,17 +1,32 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | struct TicketListView: View { | 3 | struct TicketListView: View { |
| 4 | let ownerUsername: String | 4 | let onTrackerUpdated: (TrackerSummary) -> Void |
| 5 | let trackerName: String | 5 | let onTrackerDeleted: (TrackerSummary) -> Void |
| 6 | let trackerId: Int | ||
| 7 | let trackerRid: String | ||
| 8 | 6 | ||
| 9 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true | 7 | @AppStorage(AppStorageKeys.swipeActionsEnabled) private var swipeActionsEnabled = true |
| 10 | @Environment(AppState.self) private var appState | 8 | @Environment(AppState.self) private var appState |
| 9 | @Environment(\.dismiss) private var dismiss | ||
| 10 | @State private var tracker: TrackerSummary | ||
| 11 | @State private var viewModel: TicketListViewModel? | 11 | @State private var viewModel: TicketListViewModel? |
| 12 | @State private var trackerManagementViewModel: TrackerManagementViewModel? | ||
| 12 | @State private var showCreateTicketSheet = false | 13 | @State private var showCreateTicketSheet = false |
| 13 | @State private var createdTicket: TicketSummary? | 14 | @State private var createdTicket: TicketSummary? |
| 14 | @State private var labelEditorTicket: LabelEditorTicket? | 15 | @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 | } | ||
| 15 | 30 | ||
| 16 | var body: some View { | 31 | var body: some View { |
| 17 | Group { | 32 | Group { |
| @@ -21,11 +36,17 @@ struct TicketListView: View { | |||
| 21 | SRHTLoadingStateView(message: "Loading tickets…") | 36 | SRHTLoadingStateView(message: "Loading tickets…") |
| 22 | } | 37 | } |
| 23 | } | 38 | } |
| 24 | .navigationTitle(trackerName) | 39 | .navigationTitle(tracker.name) |
| 25 | .navigationBarTitleDisplayMode(.inline) | 40 | .navigationBarTitleDisplayMode(.inline) |
| 26 | .toolbar { | 41 | .toolbar { |
| 27 | ToolbarItemGroup(placement: .topBarTrailing) { | 42 | 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 | ) { | ||
| 29 | Image(systemName: "square.and.arrow.up") | 50 | Image(systemName: "square.and.arrow.up") |
| 30 | } | 51 | } |
| 31 | 52 | ||
| @@ -36,6 +57,8 @@ struct TicketListView: View { | |||
| 36 | Image(systemName: "plus") | 57 | Image(systemName: "plus") |
| 37 | } | 58 | } |
| 38 | .accessibilityLabel("Create ticket") | 59 | .accessibilityLabel("Create ticket") |
| 60 | |||
| 61 | trackerActionsMenu | ||
| 39 | } | 62 | } |
| 40 | } | 63 | } |
| 41 | } | 64 | } |
| @@ -56,6 +79,57 @@ struct TicketListView: View { | |||
| 56 | .presentationDetents([.medium]) | 79 | .presentationDetents([.medium]) |
| 57 | } | 80 | } |
| 58 | } | 81 | } |
| 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 | } | ||
| 59 | .navigationDestination(isPresented: Binding( | 133 | .navigationDestination(isPresented: Binding( |
| 60 | get: { createdTicket != nil }, | 134 | get: { createdTicket != nil }, |
| 61 | set: { isPresented in | 135 | set: { isPresented in |
| @@ -65,19 +139,26 @@ struct TicketListView: View { | |||
| 65 | } | 139 | } |
| 66 | )) { | 140 | )) { |
| 67 | if let createdTicket { | 141 | 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 | ) | ||
| 69 | } | 149 | } |
| 70 | } | 150 | } |
| 71 | .task { | 151 | .task { |
| 72 | if viewModel == nil { | 152 | if viewModel == nil { |
| 73 | let vm = TicketListViewModel( | 153 | let vm = TicketListViewModel( |
| 74 | ownerUsername: ownerUsername, | 154 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), |
| 75 | trackerName: trackerName, | 155 | trackerName: tracker.name, |
| 76 | trackerId: trackerId, | 156 | trackerId: tracker.id, |
| 77 | trackerRid: trackerRid, | 157 | trackerRid: tracker.rid, |
| 78 | client: appState.client | 158 | client: appState.client |
| 79 | ) | 159 | ) |
| 80 | viewModel = vm | 160 | viewModel = vm |
| 161 | trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) | ||
| 81 | await vm.loadTickets() | 162 | await vm.loadTickets() |
| 82 | } | 163 | } |
| 83 | } | 164 | } |
| @@ -163,8 +244,45 @@ struct TicketListView: View { | |||
| 163 | await viewModel.loadTickets() | 244 | await viewModel.loadTickets() |
| 164 | } | 245 | } |
| 165 | .navigationDestination(for: TicketSummary.self) { ticket in | 246 | .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") | ||
| 167 | } | 284 | } |
| 285 | .accessibilityLabel("Tracker actions") | ||
| 168 | } | 286 | } |
| 169 | 287 | ||
| 170 | @ViewBuilder | 288 | @ViewBuilder |
Hutch/Views/Tickets/TicketListViewModel.swift +21 −39
| @@ -3,10 +3,6 @@ import Foundation | |||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) |
| 4 | 4 | ||
| 5 | private struct TrackerTicketsResponse: Decodable, Sendable { | 5 | private struct TrackerTicketsResponse: Decodable, Sendable { |
| 6 | let user: UserTrackerWrapper | ||
| 7 | } | ||
| 8 | |||
| 9 | private struct UserTrackerWrapper: Decodable, Sendable { | ||
| 10 | let tracker: TrackerTicketsWrapper | 6 | let tracker: TrackerTicketsWrapper |
| 11 | } | 7 | } |
| 12 | 8 | ||
| @@ -38,10 +34,6 @@ private struct LabelMutationResponse: Decodable, Sendable { | |||
| 38 | } | 34 | } |
| 39 | 35 | ||
| 40 | private struct TrackerLabelsResponse: Decodable, Sendable { | 36 | private struct TrackerLabelsResponse: Decodable, Sendable { |
| 41 | let user: UserTrackerLabelsWrapper | ||
| 42 | } | ||
| 43 | |||
| 44 | private struct UserTrackerLabelsWrapper: Decodable, Sendable { | ||
| 45 | let tracker: TrackerLabelsWrapper | 37 | let tracker: TrackerLabelsWrapper |
| 46 | } | 38 | } |
| 47 | 39 | ||
| @@ -116,22 +108,20 @@ final class TicketListViewModel { | |||
| 116 | // MARK: - Query | 108 | // MARK: - Query |
| 117 | 109 | ||
| 118 | private static let query = """ | 110 | private static let query = """ |
| 119 | query tickets($owner: String!, $tracker: String!, $cursor: Cursor) { | 111 | query tickets($rid: ID!, $cursor: Cursor) { |
| 120 | user(username: $owner) { | 112 | tracker(rid: $rid) { |
| 121 | tracker(name: $tracker) { | 113 | tickets(cursor: $cursor) { |
| 122 | tickets(cursor: $cursor) { | 114 | results { |
| 123 | results { | 115 | id |
| 124 | id | 116 | title: subject |
| 125 | title: subject | 117 | status |
| 126 | status | 118 | resolution |
| 127 | resolution | 119 | created |
| 128 | created | 120 | submitter { canonicalName } |
| 129 | submitter { canonicalName } | 121 | labels { id name backgroundColor foregroundColor } |
| 130 | labels { id name backgroundColor foregroundColor } | 122 | assignees { canonicalName } |
| 131 | assignees { canonicalName } | ||
| 132 | } | ||
| 133 | cursor | ||
| 134 | } | 123 | } |
| 124 | cursor | ||
| 135 | } | 125 | } |
| 136 | } | 126 | } |
| 137 | } | 127 | } |
| @@ -185,12 +175,10 @@ final class TicketListViewModel { | |||
| 185 | """ | 175 | """ |
| 186 | 176 | ||
| 187 | private static let trackerLabelsQuery = """ | 177 | private static let trackerLabelsQuery = """ |
| 188 | query trackerLabels($owner: String!, $tracker: String!) { | 178 | query trackerLabels($rid: ID!) { |
| 189 | user(username: $owner) { | 179 | tracker(rid: $rid) { |
| 190 | tracker(name: $tracker) { | 180 | labels { |
| 191 | labels { | 181 | results { id name backgroundColor foregroundColor } |
| 192 | results { id name backgroundColor foregroundColor } | ||
| 193 | } | ||
| 194 | } | 182 | } |
| 195 | } | 183 | } |
| 196 | } | 184 | } |
| @@ -404,13 +392,10 @@ final class TicketListViewModel { | |||
| 404 | let result = try await client.execute( | 392 | let result = try await client.execute( |
| 405 | service: .todo, | 393 | service: .todo, |
| 406 | query: Self.trackerLabelsQuery, | 394 | query: Self.trackerLabelsQuery, |
| 407 | variables: [ | 395 | variables: ["rid": trackerRid], |
| 408 | "owner": ownerUsername, | ||
| 409 | "tracker": trackerName | ||
| 410 | ], | ||
| 411 | responseType: TrackerLabelsResponse.self | 396 | responseType: TrackerLabelsResponse.self |
| 412 | ) | 397 | ) |
| 413 | trackerLabels = result.user.tracker.labels.results | 398 | trackerLabels = result.tracker.labels.results |
| 414 | } catch { | 399 | } catch { |
| 415 | self.error = error.userFacingMessage | 400 | self.error = error.userFacingMessage |
| 416 | } | 401 | } |
| @@ -530,10 +515,7 @@ final class TicketListViewModel { | |||
| 530 | } | 515 | } |
| 531 | 516 | ||
| 532 | private func fetchPage(cursor: String?) async throws -> TicketsPage { | 517 | private func fetchPage(cursor: String?) async throws -> TicketsPage { |
| 533 | var variables: [String: any Sendable] = [ | 518 | var variables: [String: any Sendable] = ["rid": trackerRid] |
| 534 | "owner": ownerUsername, | ||
| 535 | "tracker": trackerName | ||
| 536 | ] | ||
| 537 | if let cursor { | 519 | if let cursor { |
| 538 | variables["cursor"] = cursor | 520 | variables["cursor"] = cursor |
| 539 | } | 521 | } |
| @@ -543,7 +525,7 @@ final class TicketListViewModel { | |||
| 543 | variables: variables, | 525 | variables: variables, |
| 544 | responseType: TrackerTicketsResponse.self | 526 | responseType: TrackerTicketsResponse.self |
| 545 | ) | 527 | ) |
| 546 | return result.user.tracker.tickets | 528 | return result.tracker.tickets |
| 547 | } | 529 | } |
| 548 | 530 | ||
| 549 | private struct SubmitTicketResponse: Decodable, Sendable { | 531 | private struct SubmitTicketResponse: Decodable, Sendable { |
Hutch/Views/Tickets/TrackerListView.swift +105 −94
| @@ -5,6 +5,8 @@ struct TrackerListView: View { | |||
| 5 | @State private var viewModel: TrackerListViewModel? | 5 | @State private var viewModel: TrackerListViewModel? |
| 6 | @State private var showCreateTrackerSheet = false | 6 | @State private var showCreateTrackerSheet = false |
| 7 | @State private var createdTracker: TrackerSummary? | 7 | @State private var createdTracker: TrackerSummary? |
| 8 | @State private var editingTracker: TrackerSummary? | ||
| 9 | @State private var pendingDeletion: TrackerSummary? | ||
| 8 | 10 | ||
| 9 | var body: some View { | 11 | var body: some View { |
| 10 | Group { | 12 | Group { |
| @@ -28,9 +30,50 @@ struct TrackerListView: View { | |||
| 28 | } | 30 | } |
| 29 | .sheet(isPresented: $showCreateTrackerSheet) { | 31 | .sheet(isPresented: $showCreateTrackerSheet) { |
| 30 | if let viewModel { | 32 | if let viewModel { |
| 31 | CreateTrackerSheet(viewModel: viewModel) { tracker in | 33 | TrackerEditorSheet( |
| 32 | showCreateTrackerSheet = false | 34 | title: "New Tracker", |
| 33 | createdTracker = 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 | ||
| 34 | } | 77 | } |
| 35 | } | 78 | } |
| 36 | } | 79 | } |
| @@ -43,12 +86,42 @@ struct TrackerListView: View { | |||
| 43 | } | 86 | } |
| 44 | )) { | 87 | )) { |
| 45 | if let createdTracker { | 88 | if let createdTracker { |
| 46 | TicketListView( | 89 | TicketListView(tracker: createdTracker) { updatedTracker in |
| 47 | ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()), | 90 | viewModel?.applyTrackerUpdate(updatedTracker) |
| 48 | trackerName: createdTracker.name, | 91 | self.createdTracker = updatedTracker |
| 49 | trackerId: createdTracker.id, | 92 | } onTrackerDeleted: { deletedTracker in |
| 50 | trackerRid: createdTracker.rid | 93 | viewModel?.applyTrackerDeletion(deletedTracker) |
| 51 | ) | 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.") | ||
| 52 | } | 125 | } |
| 53 | } | 126 | } |
| 54 | .task { | 127 | .task { |
| @@ -69,6 +142,20 @@ struct TrackerListView: View { | |||
| 69 | NavigationLink(value: tracker) { | 142 | NavigationLink(value: tracker) { |
| 70 | TrackerRowView(tracker: tracker) | 143 | TrackerRowView(tracker: tracker) |
| 71 | } | 144 | } |
| 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 | } | ||
| 72 | .task { | 159 | .task { |
| 73 | await viewModel.loadMoreIfNeeded(currentItem: tracker) | 160 | await viewModel.loadMoreIfNeeded(currentItem: tracker) |
| 74 | } | 161 | } |
| @@ -116,92 +203,16 @@ struct TrackerListView: View { | |||
| 116 | await viewModel.loadTrackers() | 203 | await viewModel.loadTrackers() |
| 117 | } | 204 | } |
| 118 | .navigationDestination(for: TrackerSummary.self) { tracker in | 205 | .navigationDestination(for: TrackerSummary.self) { tracker in |
| 119 | TicketListView( | 206 | TicketListView(tracker: tracker) { updatedTracker in |
| 120 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | 207 | viewModel.applyTrackerUpdate(updatedTracker) |
| 121 | trackerName: tracker.name, | 208 | if createdTracker?.id == updatedTracker.id { |
| 122 | trackerId: tracker.id, | 209 | createdTracker = updatedTracker |
| 123 | trackerRid: tracker.rid | ||
| 124 | ) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | private 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 | } | ||
| 159 | } | 210 | } |
| 160 | 211 | } onTrackerDeleted: { deletedTracker in | |
| 161 | if let error = viewModel.error { | 212 | viewModel.applyTrackerDeletion(deletedTracker) |
| 162 | Section { | 213 | Task { await viewModel.loadTrackers() } |
| 163 | Label { | 214 | if createdTracker?.id == deletedTracker.id { |
| 164 | Text(error) | 215 | createdTracker = nil |
| 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) | ||
| 205 | } | 216 | } |
| 206 | } | 217 | } |
| 207 | } | 218 | } |
Hutch/Views/Tickets/TrackerListViewModel.swift +112
| @@ -11,6 +11,18 @@ private struct TrackersPage: Decodable, Sendable { | |||
| 11 | let cursor: String? | 11 | let cursor: String? |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | private struct UpdateTrackerResponse: Decodable, Sendable { | ||
| 15 | let updateTracker: TrackerSummary | ||
| 16 | } | ||
| 17 | |||
| 18 | private struct DeleteTrackerResponse: Decodable, Sendable { | ||
| 19 | let deleteTracker: DeletedTracker | ||
| 20 | } | ||
| 21 | |||
| 22 | private struct DeletedTracker: Decodable, Sendable { | ||
| 23 | let id: Int | ||
| 24 | } | ||
| 25 | |||
| 14 | // MARK: - View Model | 26 | // MARK: - View Model |
| 15 | 27 | ||
| 16 | @Observable | 28 | @Observable |
| @@ -75,6 +87,28 @@ final class TrackerListViewModel { | |||
| 75 | } | 87 | } |
| 76 | """ | 88 | """ |
| 77 | 89 | ||
| 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 | |||
| 78 | // MARK: - Public API | 112 | // MARK: - Public API |
| 79 | 113 | ||
| 80 | func loadTrackers() async { | 114 | func loadTrackers() async { |
| @@ -155,6 +189,84 @@ final class TrackerListViewModel { | |||
| 155 | } | 189 | } |
| 156 | } | 190 | } |
| 157 | 191 | ||
| 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 | |||
| 158 | // MARK: - Private | 270 | // MARK: - Private |
| 159 | 271 | ||
| 160 | private func fetchPage(cursor: String?) async throws -> TrackersPage { | 272 | private func fetchPage(cursor: String?) async throws -> TrackersPage { |
Hutch/Views/Tickets/TrackerManagementView.swift added +1268
| @@ -0,0 +1,1268 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | private struct TrackerQueryResponse: Decodable, Sendable { | ||
| 4 | let tracker: TrackerSummary? | ||
| 5 | } | ||
| 6 | |||
| 7 | private struct TrackerACLQueryResponse: Decodable, Sendable { | ||
| 8 | let tracker: TrackerACLQueryPayload? | ||
| 9 | } | ||
| 10 | |||
| 11 | private struct TrackerACLQueryPayload: Decodable, Sendable { | ||
| 12 | let defaultACL: DefaultTrackerACL | ||
| 13 | let acls: TrackerACLPage | ||
| 14 | } | ||
| 15 | |||
| 16 | private struct TrackerACLPage: Decodable, Sendable { | ||
| 17 | let results: [TrackerACL] | ||
| 18 | let cursor: String? | ||
| 19 | } | ||
| 20 | |||
| 21 | private struct TrackerLabelQueryResponse: Decodable, Sendable { | ||
| 22 | let tracker: TrackerLabelQueryPayload? | ||
| 23 | } | ||
| 24 | |||
| 25 | private struct TrackerLabelQueryPayload: Decodable, Sendable { | ||
| 26 | let labels: TrackerLabelPage | ||
| 27 | } | ||
| 28 | |||
| 29 | private struct TrackerLabelPage: Decodable, Sendable { | ||
| 30 | let results: [TicketLabel] | ||
| 31 | let cursor: String? | ||
| 32 | } | ||
| 33 | |||
| 34 | private struct UpdateTrackerResponse: Decodable, Sendable { | ||
| 35 | let updateTracker: TrackerSummary | ||
| 36 | } | ||
| 37 | |||
| 38 | private struct DeleteTrackerResponse: Decodable, Sendable { | ||
| 39 | let deleteTracker: DeletedTracker | ||
| 40 | } | ||
| 41 | |||
| 42 | private struct DeletedTracker: Decodable, Sendable { | ||
| 43 | let id: Int | ||
| 44 | } | ||
| 45 | |||
| 46 | private struct UpdateUserACLResponse: Decodable, Sendable { | ||
| 47 | let updateUserACL: TrackerACL | ||
| 48 | } | ||
| 49 | |||
| 50 | private struct UpdateTrackerACLResponse: Decodable, Sendable { | ||
| 51 | let updateTrackerACL: DefaultTrackerACL | ||
| 52 | } | ||
| 53 | |||
| 54 | private struct DeleteTrackerACLResponse: Decodable, Sendable { | ||
| 55 | let deleteACL: TrackerACL | ||
| 56 | } | ||
| 57 | |||
| 58 | private struct CreateTrackerLabelResponse: Decodable, Sendable { | ||
| 59 | let createLabel: TicketLabel | ||
| 60 | } | ||
| 61 | |||
| 62 | private struct UpdateTrackerLabelResponse: Decodable, Sendable { | ||
| 63 | let updateLabel: TicketLabel | ||
| 64 | } | ||
| 65 | |||
| 66 | private struct DeleteTrackerLabelResponse: Decodable, Sendable { | ||
| 67 | let deleteLabel: TicketLabel | ||
| 68 | } | ||
| 69 | |||
| 70 | private struct TrackerUserLookupResponse: Decodable, Sendable { | ||
| 71 | let user: UserIdPayload? | ||
| 72 | } | ||
| 73 | |||
| 74 | private struct UserIdPayload: Decodable, Sendable { | ||
| 75 | let id: Int | ||
| 76 | } | ||
| 77 | |||
| 78 | @Observable | ||
| 79 | @MainActor | ||
| 80 | final 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 | |||
| 600 | private 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 | |||
| 612 | struct 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 | |||
| 702 | struct 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 | |||
| 841 | private 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 | |||
| 862 | private 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 | |||
| 970 | private 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 | |||
| 1055 | struct 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 | |||
| 1178 | private 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 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import Hutch | ||
| 3 | |||
| 4 | struct 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 | } | ||