Commit 792c331c90
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -515,7 +515,7 @@ | ||
| 515 | 515 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 516 | 516 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 517 | 517 | CODE_SIGN_STYLE = Automatic; |
| 518 | CURRENT_PROJECT_VERSION = 45; | |
| 518 | CURRENT_PROJECT_VERSION = 46; | |
| 519 | 519 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 520 | 520 | ENABLE_PREVIEWS = YES; |
| 521 | 521 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -532,7 +532,7 @@ | ||
| 532 | 532 | "$(inherited)", |
| 533 | 533 | "@executable_path/Frameworks", |
| 534 | 534 | ); |
| 535 | MARKETING_VERSION = 2.19.2; | |
| 535 | MARKETING_VERSION = 2.19.3; | |
| 536 | 536 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 537 | 537 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 538 | 538 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -552,7 +552,7 @@ | ||
| 552 | 552 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 553 | 553 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 554 | 554 | CODE_SIGN_STYLE = Automatic; |
| 555 | CURRENT_PROJECT_VERSION = 45; | |
| 555 | CURRENT_PROJECT_VERSION = 46; | |
| 556 | 556 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 557 | 557 | ENABLE_PREVIEWS = YES; |
| 558 | 558 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -569,7 +569,7 @@ | ||
| 569 | 569 | "$(inherited)", |
| 570 | 570 | "@executable_path/Frameworks", |
| 571 | 571 | ); |
| 572 | MARKETING_VERSION = 2.19.2; | |
| 572 | MARKETING_VERSION = 2.19.3; | |
| 573 | 573 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 574 | 574 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 575 | 575 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -632,7 +632,7 @@ | ||
| 632 | 632 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 633 | 633 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 634 | 634 | CODE_SIGN_STYLE = Automatic; |
| 635 | CURRENT_PROJECT_VERSION = 45; | |
| 635 | CURRENT_PROJECT_VERSION = 46; | |
| 636 | 636 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 637 | 637 | GENERATE_INFOPLIST_FILE = NO; |
| 638 | 638 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -642,7 +642,7 @@ | ||
| 642 | 642 | "@executable_path/Frameworks", |
| 643 | 643 | "@executable_path/../../Frameworks", |
| 644 | 644 | ); |
| 645 | MARKETING_VERSION = 2.19.2; | |
| 645 | MARKETING_VERSION = 2.19.3; | |
| 646 | 646 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 647 | 647 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 648 | 648 | SKIP_INSTALL = YES; |
| @@ -661,7 +661,7 @@ | ||
| 661 | 661 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 662 | 662 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 663 | 663 | CODE_SIGN_STYLE = Automatic; |
| 664 | CURRENT_PROJECT_VERSION = 45; | |
| 664 | CURRENT_PROJECT_VERSION = 46; | |
| 665 | 665 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 666 | 666 | GENERATE_INFOPLIST_FILE = NO; |
| 667 | 667 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -671,7 +671,7 @@ | ||
| 671 | 671 | "@executable_path/Frameworks", |
| 672 | 672 | "@executable_path/../../Frameworks", |
| 673 | 673 | ); |
| 674 | MARKETING_VERSION = 2.19.2; | |
| 674 | MARKETING_VERSION = 2.19.3; | |
| 675 | 675 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 676 | 676 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 677 | 677 | SKIP_INSTALL = YES; |
Hutch/Models/TicketBulkAction.swift added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum TicketBulkActionKind: String, Sendable { | |
| 4 | case close | |
| 5 | case assign | |
| 6 | ||
| 7 | var displayName: String { | |
| 8 | switch self { | |
| 9 | case .close: | |
| 10 | "Close" | |
| 11 | case .assign: | |
| 12 | "Assign" | |
| 13 | } | |
| 14 | } | |
| 15 | ||
| 16 | var pastTenseDisplayName: String { | |
| 17 | switch self { | |
| 18 | case .close: | |
| 19 | "Closed" | |
| 20 | case .assign: | |
| 21 | "Assigned" | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | struct TicketBulkActionResult: Identifiable, Sendable { | |
| 27 | let id = UUID() | |
| 28 | let action: TicketBulkActionKind | |
| 29 | let totalCount: Int | |
| 30 | let updatedCount: Int | |
| 31 | let unchangedCount: Int | |
| 32 | let failures: [TicketBulkActionFailure] | |
| 33 | ||
| 34 | var failedCount: Int { | |
| 35 | failures.count | |
| 36 | } | |
| 37 | ||
| 38 | var title: String { | |
| 39 | if updatedCount == 0, failedCount > 0 { | |
| 40 | return "\(action.displayName) Failed" | |
| 41 | } | |
| 42 | if failedCount > 0 { | |
| 43 | return "\(action.displayName) Partially Applied" | |
| 44 | } | |
| 45 | return "\(action.displayName) Complete" | |
| 46 | } | |
| 47 | ||
| 48 | var message: String { | |
| 49 | var components: [String] = [] | |
| 50 | ||
| 51 | if updatedCount > 0 { | |
| 52 | components.append("\(action.pastTenseDisplayName) \(updatedCount) \(ticketWord(for: updatedCount)).") | |
| 53 | } | |
| 54 | ||
| 55 | if unchangedCount > 0 { | |
| 56 | let unchangedDescription: String | |
| 57 | switch action { | |
| 58 | case .close: | |
| 59 | unchangedDescription = "\(unchangedCount) already closed." | |
| 60 | case .assign: | |
| 61 | unchangedDescription = "\(unchangedCount) already assigned." | |
| 62 | } | |
| 63 | components.append(unchangedDescription) | |
| 64 | } | |
| 65 | ||
| 66 | if failedCount > 0 { | |
| 67 | let ids = failures | |
| 68 | .map { "#\($0.ticketID)" } | |
| 69 | .joined(separator: ", ") | |
| 70 | components.append("Failed: \(ids).") | |
| 71 | } | |
| 72 | ||
| 73 | if components.isEmpty { | |
| 74 | components.append("No tickets were selected.") | |
| 75 | } | |
| 76 | ||
| 77 | return components.joined(separator: " ") | |
| 78 | } | |
| 79 | ||
| 80 | private func ticketWord(for count: Int) -> String { | |
| 81 | count == 1 ? "ticket" : "tickets" | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | struct TicketBulkActionFailure: Sendable { | |
| 86 | let ticketID: Int | |
| 87 | let message: String | |
| 88 | } | |
Hutch/Views/Tickets/TicketListView.swift +316 −38
| @@ -19,6 +19,9 @@ struct TicketListView: View { | ||
| 19 | 19 | @State private var showTrackerACLs = false |
| 20 | 20 | @State private var showTrackerLabels = false |
| 21 | 21 | @State private var showDeleteTrackerConfirmation = false |
| 22 | @State private var showBulkCloseSheet = false | |
| 23 | @State private var showBulkAssignSheet = false | |
| 24 | @State private var bulkActionResult: TicketBulkActionResult? | |
| 22 | 25 | |
| 23 | 26 | private var isOwnedByCurrentUser: Bool { |
| 24 | 27 | guard let currentUser = appState.currentUser else { return false } |
| @@ -46,27 +49,46 @@ struct TicketListView: View { | ||
| 46 | 49 | .navigationTitle(tracker.name) |
| 47 | 50 | .navigationBarTitleDisplayMode(.inline) |
| 48 | 51 | .toolbar { |
| 49 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 50 | SRHTShareButton( | |
| 51 | url: SRHTWebURL.tracker( | |
| 52 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | |
| 53 | trackerName: tracker.name | |
| 54 | ), | |
| 55 | target: .tracker | |
| 56 | ) { | |
| 57 | Image(systemName: "square.and.arrow.up") | |
| 52 | ToolbarItem(placement: .topBarLeading) { | |
| 53 | if let viewModel, viewModel.isSelectionMode { | |
| 54 | Button("Cancel") { | |
| 55 | viewModel.setSelectionMode(false) | |
| 56 | } | |
| 58 | 57 | } |
| 58 | } | |
| 59 | 59 | |
| 60 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 60 | 61 | if viewModel != nil { |
| 61 | Button { | |
| 62 | showCreateTicketSheet = true | |
| 63 | } label: { | |
| 64 | Image(systemName: "plus") | |
| 65 | } | |
| 66 | .accessibilityLabel("Create ticket") | |
| 62 | if let viewModel, viewModel.isSelectionMode { | |
| 63 | Button("All") { | |
| 64 | viewModel.selectVisibleTickets(viewModel.filteredTickets) | |
| 65 | } | |
| 66 | .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction) | |
| 67 | } else { | |
| 68 | SRHTShareButton( | |
| 69 | url: SRHTWebURL.tracker( | |
| 70 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | |
| 71 | trackerName: tracker.name | |
| 72 | ), | |
| 73 | target: .tracker | |
| 74 | ) { | |
| 75 | Image(systemName: "square.and.arrow.up") | |
| 76 | } | |
| 77 | ||
| 78 | Button { | |
| 79 | showCreateTicketSheet = true | |
| 80 | } label: { | |
| 81 | Image(systemName: "plus") | |
| 82 | } | |
| 83 | .accessibilityLabel("Create ticket") | |
| 84 | ||
| 85 | Button("Select") { | |
| 86 | viewModel?.setSelectionMode(true) | |
| 87 | } | |
| 67 | 88 | |
| 68 | if isOwnedByCurrentUser { | |
| 69 | trackerActionsMenu | |
| 89 | if isOwnedByCurrentUser { | |
| 90 | trackerActionsMenu | |
| 91 | } | |
| 70 | 92 | } |
| 71 | 93 | } |
| 72 | 94 | } |
| @@ -130,12 +152,37 @@ struct TicketListView: View { | ||
| 130 | 152 | .presentationDetents([.large]) |
| 131 | 153 | } |
| 132 | 154 | } |
| 133 | .sheet(isPresented: $showTrackerLabels) { | |
| 155 | .sheet(isPresented: $showTrackerLabels, onDismiss: syncTrackerLabelsIntoTicketList) { | |
| 134 | 156 | if let trackerManagementViewModel { |
| 135 | 157 | TrackerLabelManagementSheet(viewModel: trackerManagementViewModel) |
| 136 | 158 | .presentationDetents([.large]) |
| 137 | 159 | } |
| 138 | 160 | } |
| 161 | .sheet(isPresented: $showBulkCloseSheet) { | |
| 162 | if let viewModel { | |
| 163 | BulkResolveSheet( | |
| 164 | viewModel: viewModel, | |
| 165 | isPresented: $showBulkCloseSheet, | |
| 166 | onComplete: { result in | |
| 167 | bulkActionResult = result | |
| 168 | } | |
| 169 | ) | |
| 170 | .presentationDetents([.medium]) | |
| 171 | } | |
| 172 | } | |
| 173 | .sheet(isPresented: $showBulkAssignSheet) { | |
| 174 | if let viewModel { | |
| 175 | BulkAssignSheet( | |
| 176 | viewModel: viewModel, | |
| 177 | currentUser: appState.currentUser, | |
| 178 | isPresented: $showBulkAssignSheet, | |
| 179 | onComplete: { result in | |
| 180 | bulkActionResult = result | |
| 181 | } | |
| 182 | ) | |
| 183 | .presentationDetents([.medium]) | |
| 184 | } | |
| 185 | } | |
| 139 | 186 | .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) { |
| 140 | 187 | Button("Cancel", role: .cancel) { |
| 141 | 188 | // no-op: .cancel role handles alert dismissal |
| @@ -153,6 +200,13 @@ struct TicketListView: View { | ||
| 153 | 200 | } message: { |
| 154 | 201 | Text("“\(tracker.name)” will be permanently deleted.") |
| 155 | 202 | } |
| 203 | .alert(item: $bulkActionResult) { result in | |
| 204 | Alert( | |
| 205 | title: Text(result.title), | |
| 206 | message: Text(result.message), | |
| 207 | dismissButton: .default(Text("OK")) | |
| 208 | ) | |
| 209 | } | |
| 156 | 210 | .navigationDestination(isPresented: Binding( |
| 157 | 211 | get: { createdTicket != nil }, |
| 158 | 212 | set: { isPresented in |
| @@ -199,6 +253,8 @@ struct TicketListView: View { | ||
| 199 | 253 | savedFilters: viewModel.savedFilters, |
| 200 | 254 | activeSavedFilterID: viewModel.activeSavedFilterID, |
| 201 | 255 | canSaveCurrentFilter: viewModel.hasCustomFilterSelection, |
| 256 | isSelectionMode: viewModel.isSelectionMode, | |
| 257 | selectedTicketCount: viewModel.selectedTicketCount, | |
| 202 | 258 | filter: $vm.filter |
| 203 | 259 | ) { |
| 204 | 260 | showLabelFilterSheet = true |
| @@ -216,26 +272,40 @@ struct TicketListView: View { | ||
| 216 | 272 | .listRowSeparator(.hidden) |
| 217 | 273 | |
| 218 | 274 | ForEach(viewModel.filteredTickets) { ticket in |
| 219 | NavigationLink { | |
| 220 | TicketDetailView( | |
| 221 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | |
| 222 | trackerName: tracker.name, | |
| 223 | trackerId: tracker.id, | |
| 224 | trackerRid: tracker.rid, | |
| 225 | ticketId: ticket.id | |
| 226 | ) | |
| 227 | } label: { | |
| 228 | TicketRowView(ticket: ticket) | |
| 229 | } | |
| 230 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | |
| 231 | if swipeActionsEnabled { | |
| 232 | ticketAssignSwipeAction(ticket, viewModel: viewModel) | |
| 233 | } | |
| 234 | } | |
| 235 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| 236 | if swipeActionsEnabled { | |
| 237 | ticketStatusSwipeAction(ticket, viewModel: viewModel) | |
| 238 | ticketLabelSwipeAction(ticket, viewModel: viewModel) | |
| 275 | Group { | |
| 276 | if viewModel.isSelectionMode { | |
| 277 | Button { | |
| 278 | viewModel.toggleTicketSelection(ticket) | |
| 279 | } label: { | |
| 280 | SelectableTicketRow( | |
| 281 | ticket: ticket, | |
| 282 | isSelected: viewModel.selectedTicketIDs.contains(ticket.id) | |
| 283 | ) | |
| 284 | } | |
| 285 | .buttonStyle(.plain) | |
| 286 | } else { | |
| 287 | NavigationLink { | |
| 288 | TicketDetailView( | |
| 289 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | |
| 290 | trackerName: tracker.name, | |
| 291 | trackerId: tracker.id, | |
| 292 | trackerRid: tracker.rid, | |
| 293 | ticketId: ticket.id | |
| 294 | ) | |
| 295 | } label: { | |
| 296 | TicketRowView(ticket: ticket) | |
| 297 | } | |
| 298 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | |
| 299 | if swipeActionsEnabled { | |
| 300 | ticketAssignSwipeAction(ticket, viewModel: viewModel) | |
| 301 | } | |
| 302 | } | |
| 303 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| 304 | if swipeActionsEnabled { | |
| 305 | ticketStatusSwipeAction(ticket, viewModel: viewModel) | |
| 306 | ticketLabelSwipeAction(ticket, viewModel: viewModel) | |
| 307 | } | |
| 308 | } | |
| 239 | 309 | } |
| 240 | 310 | } |
| 241 | 311 | .task { |
| @@ -287,6 +357,20 @@ struct TicketListView: View { | ||
| 287 | 357 | .refreshable { |
| 288 | 358 | await viewModel.loadTickets() |
| 289 | 359 | } |
| 360 | .safeAreaInset(edge: .bottom) { | |
| 361 | if viewModel.isSelectionMode { | |
| 362 | TicketBulkActionBar( | |
| 363 | selectedCount: viewModel.selectedTicketCount, | |
| 364 | isPerformingAction: viewModel.isPerformingAction, | |
| 365 | onClose: { | |
| 366 | showBulkCloseSheet = true | |
| 367 | }, | |
| 368 | onAssign: { | |
| 369 | showBulkAssignSheet = true | |
| 370 | } | |
| 371 | ) | |
| 372 | } | |
| 373 | } | |
| 290 | 374 | } |
| 291 | 375 | |
| 292 | 376 | private func emptyStateDescription(for viewModel: TicketListViewModel) -> String { |
| @@ -296,6 +380,11 @@ struct TicketListView: View { | ||
| 296 | 380 | return "No \(viewModel.filter.rawValue.lowercased()) tickets found." |
| 297 | 381 | } |
| 298 | 382 | |
| 383 | private func syncTrackerLabelsIntoTicketList() { | |
| 384 | guard let trackerManagementViewModel, let viewModel else { return } | |
| 385 | viewModel.syncTrackerLabels(trackerManagementViewModel.labels) | |
| 386 | } | |
| 387 | ||
| 299 | 388 | private var trackerActionsMenu: some View { |
| 300 | 389 | Menu { |
| 301 | 390 | Button { |
| @@ -535,6 +624,8 @@ private struct TicketListFilterHeader: View { | ||
| 535 | 624 | let savedFilters: [SavedTicketFilter] |
| 536 | 625 | let activeSavedFilterID: SavedTicketFilter.ID? |
| 537 | 626 | let canSaveCurrentFilter: Bool |
| 627 | let isSelectionMode: Bool | |
| 628 | let selectedTicketCount: Int | |
| 538 | 629 | @Binding var filter: TicketFilter |
| 539 | 630 | let onShowLabels: () -> Void |
| 540 | 631 | let onSaveFilter: () -> Void |
| @@ -544,12 +635,19 @@ private struct TicketListFilterHeader: View { | ||
| 544 | 635 | |
| 545 | 636 | var body: some View { |
| 546 | 637 | VStack(alignment: .leading, spacing: 10) { |
| 638 | if isSelectionMode { | |
| 639 | Text(selectionSummary) | |
| 640 | .font(.subheadline.weight(.medium)) | |
| 641 | .foregroundStyle(.secondary) | |
| 642 | } | |
| 643 | ||
| 547 | 644 | Picker("Filter", selection: $filter) { |
| 548 | 645 | ForEach(TicketFilter.allCases, id: \.self) { filter in |
| 549 | 646 | Text(filter.rawValue).tag(filter) |
| 550 | 647 | } |
| 551 | 648 | } |
| 552 | 649 | .pickerStyle(.segmented) |
| 650 | .disabled(isSelectionMode) | |
| 553 | 651 | |
| 554 | 652 | TicketQuickFilterBar( |
| 555 | 653 | selectedLabels: selectedLabels, |
| @@ -562,11 +660,16 @@ private struct TicketListFilterHeader: View { | ||
| 562 | 660 | onApplySavedFilter: onApplySavedFilter, |
| 563 | 661 | onDeleteSavedFilter: onDeleteSavedFilter |
| 564 | 662 | ) |
| 663 | .disabled(isSelectionMode) | |
| 565 | 664 | } |
| 566 | 665 | .padding(.horizontal, 16) |
| 567 | 666 | .padding(.top, 6) |
| 568 | 667 | .padding(.bottom, 10) |
| 569 | 668 | } |
| 669 | ||
| 670 | private var selectionSummary: String { | |
| 671 | selectedTicketCount == 0 ? "Select tickets for bulk actions." : "\(selectedTicketCount) selected" | |
| 672 | } | |
| 570 | 673 | } |
| 571 | 674 | |
| 572 | 675 | private struct TicketQuickFilterBar: View { |
| @@ -810,6 +913,181 @@ private struct TicketListLabelToggleRow: View { | ||
| 810 | 913 | } |
| 811 | 914 | } |
| 812 | 915 | |
| 916 | private struct TicketBulkActionBar: View { | |
| 917 | let selectedCount: Int | |
| 918 | let isPerformingAction: Bool | |
| 919 | let onClose: () -> Void | |
| 920 | let onAssign: () -> Void | |
| 921 | ||
| 922 | var body: some View { | |
| 923 | VStack(spacing: 12) { | |
| 924 | HStack { | |
| 925 | Text(selectedCount == 0 ? "Select tickets to continue" : "\(selectedCount) selected") | |
| 926 | .font(.subheadline.weight(.medium)) | |
| 927 | .foregroundStyle(.secondary) | |
| 928 | Spacer() | |
| 929 | } | |
| 930 | ||
| 931 | HStack(spacing: 12) { | |
| 932 | Button("Close") { | |
| 933 | onClose() | |
| 934 | } | |
| 935 | .buttonStyle(.borderedProminent) | |
| 936 | .tint(.red) | |
| 937 | .disabled(selectedCount == 0 || isPerformingAction) | |
| 938 | ||
| 939 | Button("Assign") { | |
| 940 | onAssign() | |
| 941 | } | |
| 942 | .buttonStyle(.bordered) | |
| 943 | .disabled(selectedCount == 0 || isPerformingAction) | |
| 944 | ||
| 945 | if isPerformingAction { | |
| 946 | ProgressView() | |
| 947 | .controlSize(.small) | |
| 948 | } | |
| 949 | } | |
| 950 | } | |
| 951 | .padding(.horizontal, 16) | |
| 952 | .padding(.top, 12) | |
| 953 | .padding(.bottom, 16) | |
| 954 | .background(.ultraThinMaterial) | |
| 955 | } | |
| 956 | } | |
| 957 | ||
| 958 | private struct BulkResolveSheet: View { | |
| 959 | let viewModel: TicketListViewModel | |
| 960 | @Binding var isPresented: Bool | |
| 961 | let onComplete: (TicketBulkActionResult) -> Void | |
| 962 | @State private var selectedResolution: TicketResolution = .fixed | |
| 963 | ||
| 964 | private static let resolutionOptions: [TicketResolution] = [ | |
| 965 | .closed, .fixed, .implemented, .wontFix, | |
| 966 | .byDesign, .invalid, .duplicate, .notOurBug | |
| 967 | ] | |
| 968 | ||
| 969 | var body: some View { | |
| 970 | NavigationStack { | |
| 971 | Form { | |
| 972 | Section { | |
| 973 | Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected") | |
| 974 | .font(.subheadline) | |
| 975 | .foregroundStyle(.secondary) | |
| 976 | } | |
| 977 | ||
| 978 | Section("Resolution") { | |
| 979 | Picker("Resolution", selection: $selectedResolution) { | |
| 980 | ForEach(Self.resolutionOptions, id: \.self) { resolution in | |
| 981 | Text(resolution.displayName).tag(resolution) | |
| 982 | } | |
| 983 | } | |
| 984 | .pickerStyle(.inline) | |
| 985 | .labelsHidden() | |
| 986 | } | |
| 987 | } | |
| 988 | .navigationTitle("Close Tickets") | |
| 989 | .navigationBarTitleDisplayMode(.inline) | |
| 990 | .toolbar { | |
| 991 | ToolbarItem(placement: .cancellationAction) { | |
| 992 | Button("Cancel") { isPresented = false } | |
| 993 | } | |
| 994 | ToolbarItem(placement: .confirmationAction) { | |
| 995 | Button("Close") { | |
| 996 | Task { | |
| 997 | if let result = await viewModel.closeSelectedTickets(resolution: selectedResolution) { | |
| 998 | onComplete(result) | |
| 999 | isPresented = false | |
| 1000 | } | |
| 1001 | } | |
| 1002 | } | |
| 1003 | .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction) | |
| 1004 | } | |
| 1005 | } | |
| 1006 | } | |
| 1007 | } | |
| 1008 | } | |
| 1009 | ||
| 1010 | private struct BulkAssignSheet: View { | |
| 1011 | let viewModel: TicketListViewModel | |
| 1012 | let currentUser: User? | |
| 1013 | @Binding var isPresented: Bool | |
| 1014 | let onComplete: (TicketBulkActionResult) -> Void | |
| 1015 | @State private var username = "" | |
| 1016 | ||
| 1017 | var body: some View { | |
| 1018 | NavigationStack { | |
| 1019 | Form { | |
| 1020 | Section { | |
| 1021 | Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected") | |
| 1022 | .font(.subheadline) | |
| 1023 | .foregroundStyle(.secondary) | |
| 1024 | } | |
| 1025 | ||
| 1026 | if let currentUser { | |
| 1027 | Section { | |
| 1028 | Button("Assign to Me") { | |
| 1029 | Task { | |
| 1030 | if let result = await viewModel.assignSelectedTickets(username: currentUser.username) { | |
| 1031 | onComplete(result) | |
| 1032 | isPresented = false | |
| 1033 | } | |
| 1034 | } | |
| 1035 | } | |
| 1036 | .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction) | |
| 1037 | } | |
| 1038 | } | |
| 1039 | ||
| 1040 | Section("Assign User") { | |
| 1041 | TextField("Username or ~username", text: $username) | |
| 1042 | .textContentType(.username) | |
| 1043 | .autocorrectionDisabled() | |
| 1044 | .textInputAutocapitalization(.never) | |
| 1045 | } | |
| 1046 | } | |
| 1047 | .navigationTitle("Assign Tickets") | |
| 1048 | .navigationBarTitleDisplayMode(.inline) | |
| 1049 | .toolbar { | |
| 1050 | ToolbarItem(placement: .cancellationAction) { | |
| 1051 | Button("Cancel") { isPresented = false } | |
| 1052 | } | |
| 1053 | ToolbarItem(placement: .confirmationAction) { | |
| 1054 | Button("Assign") { | |
| 1055 | Task { | |
| 1056 | if let result = await viewModel.assignSelectedTickets(username: username) { | |
| 1057 | onComplete(result) | |
| 1058 | isPresented = false | |
| 1059 | } | |
| 1060 | } | |
| 1061 | } | |
| 1062 | .disabled( | |
| 1063 | username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 1064 | || viewModel.selectedTicketCount == 0 | |
| 1065 | || viewModel.isPerformingAction | |
| 1066 | ) | |
| 1067 | } | |
| 1068 | } | |
| 1069 | } | |
| 1070 | } | |
| 1071 | } | |
| 1072 | ||
| 1073 | private struct SelectableTicketRow: View { | |
| 1074 | let ticket: TicketSummary | |
| 1075 | let isSelected: Bool | |
| 1076 | ||
| 1077 | var body: some View { | |
| 1078 | HStack(alignment: .top, spacing: 12) { | |
| 1079 | Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") | |
| 1080 | .font(.title3) | |
| 1081 | .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) | |
| 1082 | .padding(.top, 4) | |
| 1083 | ||
| 1084 | TicketRowView(ticket: ticket) | |
| 1085 | } | |
| 1086 | .padding(.vertical, 2) | |
| 1087 | .contentShape(Rectangle()) | |
| 1088 | } | |
| 1089 | } | |
| 1090 | ||
| 813 | 1091 | // MARK: - Ticket Row |
| 814 | 1092 | |
| 815 | 1093 | private struct TicketRowView: View { |
Hutch/Views/Tickets/TicketListViewModel.swift +258 −1
| @@ -53,6 +53,14 @@ private struct MutationEventRef: Decodable, Sendable { | ||
| 53 | 53 | let eventType: String |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | private struct TicketListUserLookupResponse: Decodable, Sendable { | |
| 57 | let user: TicketListUserIDPayload? | |
| 58 | } | |
| 59 | ||
| 60 | private struct TicketListUserIDPayload: Decodable, Sendable { | |
| 61 | let id: Int | |
| 62 | } | |
| 63 | ||
| 56 | 64 | // MARK: - Filter |
| 57 | 65 | |
| 58 | 66 | enum TicketFilter: String, CaseIterable, Codable, Sendable { |
| @@ -78,6 +86,8 @@ final class TicketListViewModel { | ||
| 78 | 86 | private(set) var isPerformingAction = false |
| 79 | 87 | private(set) var trackerLabels: [TicketLabel] = [] |
| 80 | 88 | private(set) var savedFilters: [SavedTicketFilter] |
| 89 | private(set) var isSelectionMode = false | |
| 90 | private(set) var selectedTicketIDs: Set<Int> = [] | |
| 81 | 91 | var error: String? |
| 82 | 92 | var filter: TicketFilter = .open { |
| 83 | 93 | didSet { |
| @@ -198,6 +208,12 @@ final class TicketListViewModel { | ||
| 198 | 208 | } |
| 199 | 209 | """ |
| 200 | 210 | |
| 211 | private static let userLookupQuery = """ | |
| 212 | query userLookup($username: String!) { | |
| 213 | user(username: $username) { id } | |
| 214 | } | |
| 215 | """ | |
| 216 | ||
| 201 | 217 | // MARK: - Computed |
| 202 | 218 | |
| 203 | 219 | var currentFilterState: TicketListFilterState { |
| @@ -216,6 +232,14 @@ final class TicketListViewModel { | ||
| 216 | 232 | availableLabels.filter { selectedLabelIDs.contains($0.id) } |
| 217 | 233 | } |
| 218 | 234 | |
| 235 | var selectedTickets: [TicketSummary] { | |
| 236 | tickets.filter { selectedTicketIDs.contains($0.id) } | |
| 237 | } | |
| 238 | ||
| 239 | var selectedTicketCount: Int { | |
| 240 | selectedTicketIDs.count | |
| 241 | } | |
| 242 | ||
| 219 | 243 | var suggestedSavedFilterName: String { |
| 220 | 244 | let labelNames = selectedLabels.map(\.name).sorted() |
| 221 | 245 | var components: [String] = [] |
| @@ -252,6 +276,7 @@ final class TicketListViewModel { | ||
| 252 | 276 | tickets = page.results |
| 253 | 277 | cursor = page.cursor |
| 254 | 278 | hasMore = page.cursor != nil |
| 279 | reconcileSelectionWithLoadedTickets() | |
| 255 | 280 | } catch { |
| 256 | 281 | self.error = error.userFacingMessage |
| 257 | 282 | } |
| @@ -274,6 +299,7 @@ final class TicketListViewModel { | ||
| 274 | 299 | tickets.append(contentsOf: page.results) |
| 275 | 300 | cursor = page.cursor |
| 276 | 301 | hasMore = page.cursor != nil |
| 302 | reconcileSelectionWithLoadedTickets() | |
| 277 | 303 | } catch { |
| 278 | 304 | self.error = error.userFacingMessage |
| 279 | 305 | } |
| @@ -427,12 +453,18 @@ final class TicketListViewModel { | ||
| 427 | 453 | variables: ["rid": trackerRid], |
| 428 | 454 | responseType: TrackerLabelsResponse.self |
| 429 | 455 | ) |
| 430 | trackerLabels = result.tracker.labels.results | |
| 456 | syncTrackerLabels(result.tracker.labels.results) | |
| 431 | 457 | } catch { |
| 432 | 458 | self.error = error.userFacingMessage |
| 433 | 459 | } |
| 434 | 460 | } |
| 435 | 461 | |
| 462 | func syncTrackerLabels(_ labels: [TicketLabel]) { | |
| 463 | trackerLabels = labels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } | |
| 464 | tickets = Self.synchronizeTickets(tickets, with: trackerLabels) | |
| 465 | selectedLabelIDs = Self.reconciledSelectedLabelIDs(selectedLabelIDs, availableLabels: trackerLabels) | |
| 466 | } | |
| 467 | ||
| 436 | 468 | func toggleLabelSelection(_ label: TicketLabel) { |
| 437 | 469 | if selectedLabelIDs.contains(label.id) { |
| 438 | 470 | selectedLabelIDs.remove(label.id) |
| @@ -445,6 +477,29 @@ final class TicketListViewModel { | ||
| 445 | 477 | selectedLabelIDs = [] |
| 446 | 478 | } |
| 447 | 479 | |
| 480 | func setSelectionMode(_ enabled: Bool) { | |
| 481 | isSelectionMode = enabled | |
| 482 | if !enabled { | |
| 483 | clearTicketSelection() | |
| 484 | } | |
| 485 | } | |
| 486 | ||
| 487 | func toggleTicketSelection(_ ticket: TicketSummary) { | |
| 488 | if selectedTicketIDs.contains(ticket.id) { | |
| 489 | selectedTicketIDs.remove(ticket.id) | |
| 490 | } else { | |
| 491 | selectedTicketIDs.insert(ticket.id) | |
| 492 | } | |
| 493 | } | |
| 494 | ||
| 495 | func selectVisibleTickets(_ tickets: [TicketSummary]) { | |
| 496 | selectedTicketIDs = Set(tickets.map(\.id)) | |
| 497 | } | |
| 498 | ||
| 499 | func clearTicketSelection() { | |
| 500 | selectedTicketIDs = [] | |
| 501 | } | |
| 502 | ||
| 448 | 503 | func resetFilters() { |
| 449 | 504 | filter = .open |
| 450 | 505 | selectedLabelIDs = [] |
| @@ -564,6 +619,72 @@ final class TicketListViewModel { | ||
| 564 | 619 | tickets.first(where: { $0.id == ticketId }) |
| 565 | 620 | } |
| 566 | 621 | |
| 622 | func closeSelectedTickets(resolution: TicketResolution) async -> TicketBulkActionResult? { | |
| 623 | await performBulkAction( | |
| 624 | kind: .close, | |
| 625 | prepare: { ticket in | |
| 626 | guard ticket.status != .resolved else { return .unchanged } | |
| 627 | ||
| 628 | let input = Self.bulkStatusUpdateInput(resolution: resolution) | |
| 629 | let updatedTicket = updatedTicket(from: ticket, input: input) | |
| 630 | return .request(updatedTicket: updatedTicket) { | |
| 631 | try await self.executeBulkStatusUpdate(ticketID: ticket.id, input: input) | |
| 632 | } | |
| 633 | } | |
| 634 | ) | |
| 635 | } | |
| 636 | ||
| 637 | func assignSelectedTickets(username: String) async -> TicketBulkActionResult? { | |
| 638 | let normalizedUsername = Self.normalizedUsername(username) | |
| 639 | guard !normalizedUsername.isEmpty else { | |
| 640 | error = "Enter a SourceHut username." | |
| 641 | return nil | |
| 642 | } | |
| 643 | ||
| 644 | do { | |
| 645 | let userResult = try await client.execute( | |
| 646 | service: .todo, | |
| 647 | query: Self.userLookupQuery, | |
| 648 | variables: ["username": normalizedUsername], | |
| 649 | responseType: TicketListUserLookupResponse.self | |
| 650 | ) | |
| 651 | guard let userID = userResult.user?.id else { | |
| 652 | error = "That user couldn’t be found." | |
| 653 | return nil | |
| 654 | } | |
| 655 | ||
| 656 | let assignee = Entity(canonicalName: Self.normalizedCanonicalName(normalizedUsername)) | |
| 657 | return await performBulkAction( | |
| 658 | kind: .assign, | |
| 659 | prepare: { ticket in | |
| 660 | guard !ticket.assignees.contains(where: { | |
| 661 | Self.normalizedCanonicalName($0.canonicalName) == assignee.canonicalName | |
| 662 | }) else { | |
| 663 | return .unchanged | |
| 664 | } | |
| 665 | ||
| 666 | let updatedTicket = TicketSummary( | |
| 667 | id: ticket.id, | |
| 668 | title: ticket.title, | |
| 669 | status: ticket.status, | |
| 670 | resolution: ticket.resolution, | |
| 671 | created: ticket.created, | |
| 672 | submitter: ticket.submitter, | |
| 673 | labels: ticket.labels, | |
| 674 | assignees: ticket.assignees + [assignee] | |
| 675 | ) | |
| 676 | ||
| 677 | return .request(updatedTicket: updatedTicket) { | |
| 678 | try await self.executeBulkAssign(ticketID: ticket.id, userID: userID) | |
| 679 | } | |
| 680 | } | |
| 681 | ) | |
| 682 | } catch { | |
| 683 | self.error = error.userFacingMessage | |
| 684 | return nil | |
| 685 | } | |
| 686 | } | |
| 687 | ||
| 567 | 688 | // MARK: - Private |
| 568 | 689 | |
| 569 | 690 | private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async { |
| @@ -655,6 +776,137 @@ final class TicketListViewModel { | ||
| 655 | 776 | activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id |
| 656 | 777 | } |
| 657 | 778 | |
| 779 | private func reconcileSelectionWithLoadedTickets() { | |
| 780 | let loadedTicketIDs = Set(tickets.map(\.id)) | |
| 781 | selectedTicketIDs.formIntersection(loadedTicketIDs) | |
| 782 | if isSelectionMode, selectedTicketIDs.isEmpty { | |
| 783 | isSelectionMode = false | |
| 784 | } | |
| 785 | } | |
| 786 | ||
| 787 | private func performBulkAction( | |
| 788 | kind: TicketBulkActionKind, | |
| 789 | prepare: (TicketSummary) -> TicketBulkTicketOperation | |
| 790 | ) async -> TicketBulkActionResult? { | |
| 791 | guard !isPerformingAction else { return nil } | |
| 792 | ||
| 793 | let selected = selectedTickets | |
| 794 | guard !selected.isEmpty else { return nil } | |
| 795 | ||
| 796 | isPerformingAction = true | |
| 797 | error = nil | |
| 798 | ||
| 799 | var updatedCount = 0 | |
| 800 | var unchangedCount = 0 | |
| 801 | var failures: [TicketBulkActionFailure] = [] | |
| 802 | var failedTicketIDs = Set<Int>() | |
| 803 | ||
| 804 | for ticket in selected { | |
| 805 | switch prepare(ticket) { | |
| 806 | case .unchanged: | |
| 807 | unchangedCount += 1 | |
| 808 | case .request(let updatedTicket, let request): | |
| 809 | replaceTicket(updatedTicket) | |
| 810 | do { | |
| 811 | try await request() | |
| 812 | updatedCount += 1 | |
| 813 | } catch { | |
| 814 | replaceTicket(ticket) | |
| 815 | failedTicketIDs.insert(ticket.id) | |
| 816 | failures.append( | |
| 817 | TicketBulkActionFailure( | |
| 818 | ticketID: ticket.id, | |
| 819 | message: error.userFacingMessage | |
| 820 | ) | |
| 821 | ) | |
| 822 | } | |
| 823 | } | |
| 824 | } | |
| 825 | ||
| 826 | isPerformingAction = false | |
| 827 | ||
| 828 | let result = TicketBulkActionResult( | |
| 829 | action: kind, | |
| 830 | totalCount: selected.count, | |
| 831 | updatedCount: updatedCount, | |
| 832 | unchangedCount: unchangedCount, | |
| 833 | failures: failures | |
| 834 | ) | |
| 835 | ||
| 836 | if failedTicketIDs.isEmpty { | |
| 837 | clearTicketSelection() | |
| 838 | isSelectionMode = false | |
| 839 | } else { | |
| 840 | selectedTicketIDs = failedTicketIDs | |
| 841 | isSelectionMode = true | |
| 842 | } | |
| 843 | ||
| 844 | return result | |
| 845 | } | |
| 846 | ||
| 847 | private func replaceTicket(_ ticket: TicketSummary) { | |
| 848 | guard let index = tickets.firstIndex(where: { $0.id == ticket.id }) else { return } | |
| 849 | tickets[index] = ticket | |
| 850 | } | |
| 851 | ||
| 852 | private func executeBulkStatusUpdate(ticketID: Int, input: [String: any Sendable]) async throws { | |
| 853 | _ = try await client.execute( | |
| 854 | service: .todo, | |
| 855 | query: Self.updateStatusMutation, | |
| 856 | variables: [ | |
| 857 | "trackerId": trackerId, | |
| 858 | "ticketId": ticketID, | |
| 859 | "input": input | |
| 860 | ], | |
| 861 | responseType: UpdateStatusResponse.self | |
| 862 | ) | |
| 863 | } | |
| 864 | ||
| 865 | private func executeBulkAssign(ticketID: Int, userID: Int) async throws { | |
| 866 | _ = try await client.execute( | |
| 867 | service: .todo, | |
| 868 | query: Self.assignUserMutation, | |
| 869 | variables: [ | |
| 870 | "trackerId": trackerId, | |
| 871 | "ticketId": ticketID, | |
| 872 | "userId": userID | |
| 873 | ], | |
| 874 | responseType: AssignmentMutationResponse.self | |
| 875 | ) | |
| 876 | } | |
| 877 | ||
| 878 | private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] { | |
| 879 | [ | |
| 880 | "status": TicketStatus.resolved.rawValue, | |
| 881 | "resolution": resolution.rawValue | |
| 882 | ] | |
| 883 | } | |
| 884 | ||
| 885 | static func synchronizeTickets(_ tickets: [TicketSummary], with labels: [TicketLabel]) -> [TicketSummary] { | |
| 886 | let labelsByID = Dictionary(uniqueKeysWithValues: labels.map { ($0.id, $0) }) | |
| 887 | ||
| 888 | return tickets.map { ticket in | |
| 889 | let updatedLabels = ticket.labels.compactMap { labelsByID[$0.id] } | |
| 890 | return TicketSummary( | |
| 891 | id: ticket.id, | |
| 892 | title: ticket.title, | |
| 893 | status: ticket.status, | |
| 894 | resolution: ticket.resolution, | |
| 895 | created: ticket.created, | |
| 896 | submitter: ticket.submitter, | |
| 897 | labels: updatedLabels, | |
| 898 | assignees: ticket.assignees | |
| 899 | ) | |
| 900 | } | |
| 901 | } | |
| 902 | ||
| 903 | static func reconciledSelectedLabelIDs( | |
| 904 | _ selectedLabelIDs: Set<Int>, | |
| 905 | availableLabels: [TicketLabel] | |
| 906 | ) -> Set<Int> { | |
| 907 | selectedLabelIDs.intersection(Set(availableLabels.map(\.id))) | |
| 908 | } | |
| 909 | ||
| 658 | 910 | static func filterTickets( |
| 659 | 911 | _ tickets: [TicketSummary], |
| 660 | 912 | state: TicketListFilterState, |
| @@ -690,3 +942,8 @@ final class TicketListViewModel { | ||
| 690 | 942 | } |
| 691 | 943 | } |
| 692 | 944 | } |
| 945 | ||
| 946 | private enum TicketBulkTicketOperation { | |
| 947 | case unchanged | |
| 948 | case request(updatedTicket: TicketSummary, operation: @Sendable () async throws -> Void) | |
| 949 | } | |
Hutch/Views/Tickets/TrackerManagementView.swift +49 −3
| @@ -1069,6 +1069,12 @@ struct TrackerLabelManagementSheet: View { | ||
| 1069 | 1069 | var body: some View { |
| 1070 | 1070 | NavigationStack { |
| 1071 | 1071 | List { |
| 1072 | Section { | |
| 1073 | Text("Labels are managed here and reused throughout the tracker.") | |
| 1074 | .font(.footnote) | |
| 1075 | .foregroundStyle(.secondary) | |
| 1076 | } | |
| 1077 | ||
| 1072 | 1078 | if viewModel.isLoadingLabels { |
| 1073 | 1079 | HStack { |
| 1074 | 1080 | Spacer() |
| @@ -1083,10 +1089,12 @@ struct TrackerLabelManagementSheet: View { | ||
| 1083 | 1089 | ) |
| 1084 | 1090 | } else { |
| 1085 | 1091 | ForEach(viewModel.labels) { label in |
| 1086 | HStack { | |
| 1087 | LabelPill(label: label) | |
| 1088 | Spacer() | |
| 1092 | Button { | |
| 1093 | editingLabel = label | |
| 1094 | } label: { | |
| 1095 | TrackerLabelManagementRow(label: label) | |
| 1089 | 1096 | } |
| 1097 | .buttonStyle(.plain) | |
| 1090 | 1098 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 1091 | 1099 | Button { |
| 1092 | 1100 | editingLabel = label |
| @@ -1178,6 +1186,44 @@ struct TrackerLabelManagementSheet: View { | ||
| 1178 | 1186 | } |
| 1179 | 1187 | } |
| 1180 | 1188 | |
| 1189 | private struct TrackerLabelManagementRow: View { | |
| 1190 | let label: TicketLabel | |
| 1191 | ||
| 1192 | var body: some View { | |
| 1193 | VStack(alignment: .leading, spacing: 8) { | |
| 1194 | HStack(alignment: .top, spacing: 12) { | |
| 1195 | LabelPill(label: label) | |
| 1196 | Spacer() | |
| 1197 | Image(systemName: "chevron.right") | |
| 1198 | .font(.caption.weight(.semibold)) | |
| 1199 | .foregroundStyle(.tertiary) | |
| 1200 | } | |
| 1201 | ||
| 1202 | HStack(spacing: 12) { | |
| 1203 | colorSwatch(hex: label.backgroundColor, title: "Background") | |
| 1204 | colorSwatch(hex: label.foregroundColor, title: "Text") | |
| 1205 | } | |
| 1206 | } | |
| 1207 | .padding(.vertical, 4) | |
| 1208 | } | |
| 1209 | ||
| 1210 | private func colorSwatch(hex: String, title: String) -> some View { | |
| 1211 | HStack(spacing: 6) { | |
| 1212 | Circle() | |
| 1213 | .fill(Color(hex: hex) ?? .clear) | |
| 1214 | .frame(width: 10, height: 10) | |
| 1215 | .overlay { | |
| 1216 | Circle() | |
| 1217 | .stroke(Color.secondary.opacity(0.2), lineWidth: 1) | |
| 1218 | } | |
| 1219 | ||
| 1220 | Text("\(title): \(hex.uppercased())") | |
| 1221 | .font(.caption) | |
| 1222 | .foregroundStyle(.secondary) | |
| 1223 | } | |
| 1224 | } | |
| 1225 | } | |
| 1226 | ||
| 1181 | 1227 | private struct TrackerLabelEditorSheet: View { |
| 1182 | 1228 | let title: String |
| 1183 | 1229 | let submitTitle: String |
HutchTests/TicketBulkActionTests.swift added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | import Testing | |
| 2 | @testable import Hutch | |
| 3 | ||
| 4 | struct TicketBulkActionTests { | |
| 5 | ||
| 6 | @Test | |
| 7 | func bulkActionResultFormatsSuccessMessage() { | |
| 8 | let result = TicketBulkActionResult( | |
| 9 | action: .close, | |
| 10 | totalCount: 3, | |
| 11 | updatedCount: 3, | |
| 12 | unchangedCount: 0, | |
| 13 | failures: [] | |
| 14 | ) | |
| 15 | ||
| 16 | #expect(result.title == "Close Complete") | |
| 17 | #expect(result.message == "Closed 3 tickets.") | |
| 18 | } | |
| 19 | ||
| 20 | @Test | |
| 21 | func bulkActionResultFormatsPartialFailureMessage() { | |
| 22 | let result = TicketBulkActionResult( | |
| 23 | action: .assign, | |
| 24 | totalCount: 4, | |
| 25 | updatedCount: 2, | |
| 26 | unchangedCount: 1, | |
| 27 | failures: [ | |
| 28 | TicketBulkActionFailure(ticketID: 42, message: "Network error") | |
| 29 | ] | |
| 30 | ) | |
| 31 | ||
| 32 | #expect(result.title == "Assign Partially Applied") | |
| 33 | #expect(result.message == "Assigned 2 tickets. 1 already assigned. Failed: #42.") | |
| 34 | } | |
| 35 | } | |
HutchTests/TicketListViewModelTests.swift +38
| @@ -2,6 +2,7 @@ import Foundation | ||
| 2 | 2 | import Testing |
| 3 | 3 | @testable import Hutch |
| 4 | 4 | |
| 5 | @MainActor | |
| 5 | 6 | struct TicketListViewModelTests { |
| 6 | 7 | |
| 7 | 8 | @Test |
| @@ -81,6 +82,43 @@ struct TicketListViewModelTests { | ||
| 81 | 82 | #expect(filtered.map(\.id) == [2]) |
| 82 | 83 | } |
| 83 | 84 | |
| 85 | @Test | |
| 86 | func synchronizeTicketsReplacesEditedLabelsAndRemovesDeletedOnes() { | |
| 87 | let existing = [ | |
| 88 | makeTicket( | |
| 89 | id: 7, | |
| 90 | title: "Triage me", | |
| 91 | status: .reported, | |
| 92 | submitter: "~owner", | |
| 93 | labels: [ | |
| 94 | makeLabel(id: 1, name: "bug"), | |
| 95 | makeLabel(id: 2, name: "stale") | |
| 96 | ] | |
| 97 | ) | |
| 98 | ] | |
| 99 | ||
| 100 | let updated = TicketListViewModel.synchronizeTickets( | |
| 101 | existing, | |
| 102 | with: [makeLabel(id: 1, name: "bugfix")] | |
| 103 | ) | |
| 104 | ||
| 105 | #expect(updated.first?.labels.map(\.id) == [1]) | |
| 106 | #expect(updated.first?.labels.first?.name == "bugfix") | |
| 107 | } | |
| 108 | ||
| 109 | @Test | |
| 110 | func reconciledSelectedLabelIDsRemovesUnknownIDs() { | |
| 111 | let reconciled = TicketListViewModel.reconciledSelectedLabelIDs( | |
| 112 | [1, 2, 5], | |
| 113 | availableLabels: [ | |
| 114 | makeLabel(id: 2, name: "triage"), | |
| 115 | makeLabel(id: 3, name: "qa") | |
| 116 | ] | |
| 117 | ) | |
| 118 | ||
| 119 | #expect(reconciled == [2]) | |
| 120 | } | |
| 121 | ||
| 84 | 122 | @Test |
| 85 | 123 | @MainActor |
| 86 | 124 | func resolveTicketInputHasCorrectStatusAndDefaultResolution() { |