Commit 792c331c90

792c331c90aaf4990563108eb1244eaaa0199b3c

parent: e8d7a07087

Unsigned

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

feat: add ticket label management and bulk actions

Implements: https://todo.sr.ht/~ccleberg/hutch/45
Implements: https://todo.sr.ht/~ccleberg/hutch/46

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -515,7 +515,7 @@
515515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 45;
518 CURRENT_PROJECT_VERSION = 46;
519519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520520 ENABLE_PREVIEWS = YES;
521521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532532 "$(inherited)",
533533 "@executable_path/Frameworks",
534534 );
535 MARKETING_VERSION = 2.19.2;
535 MARKETING_VERSION = 2.19.3;
536536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537537 PRODUCT_NAME = "$(TARGET_NAME)";
538538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 45;
555 CURRENT_PROJECT_VERSION = 46;
556556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557557 ENABLE_PREVIEWS = YES;
558558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569569 "$(inherited)",
570570 "@executable_path/Frameworks",
571571 );
572 MARKETING_VERSION = 2.19.2;
572 MARKETING_VERSION = 2.19.3;
573573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574574 PRODUCT_NAME = "$(TARGET_NAME)";
575575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632632 APPLICATION_EXTENSION_API_ONLY = YES;
633633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 45;
635 CURRENT_PROJECT_VERSION = 46;
636636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637637 GENERATE_INFOPLIST_FILE = NO;
638638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642642 "@executable_path/Frameworks",
643643 "@executable_path/../../Frameworks",
644644 );
645 MARKETING_VERSION = 2.19.2;
645 MARKETING_VERSION = 2.19.3;
646646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647647 PRODUCT_NAME = "$(TARGET_NAME)";
648648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661661 APPLICATION_EXTENSION_API_ONLY = YES;
662662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 45;
664 CURRENT_PROJECT_VERSION = 46;
665665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666666 GENERATE_INFOPLIST_FILE = NO;
667667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671671 "@executable_path/Frameworks",
672672 "@executable_path/../../Frameworks",
673673 );
674 MARKETING_VERSION = 2.19.2;
674 MARKETING_VERSION = 2.19.3;
675675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676676 PRODUCT_NAME = "$(TARGET_NAME)";
677677 SKIP_INSTALL = YES;
Hutch/Models/TicketBulkAction.swift added +88
@@ -0,0 +1,88 @@
1import Foundation
2
3enum 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
26struct 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
85struct 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 {
1919 @State private var showTrackerACLs = false
2020 @State private var showTrackerLabels = false
2121 @State private var showDeleteTrackerConfirmation = false
22 @State private var showBulkCloseSheet = false
23 @State private var showBulkAssignSheet = false
24 @State private var bulkActionResult: TicketBulkActionResult?
2225
2326 private var isOwnedByCurrentUser: Bool {
2427 guard let currentUser = appState.currentUser else { return false }
@@ -46,27 +49,46 @@ struct TicketListView: View {
4649 .navigationTitle(tracker.name)
4750 .navigationBarTitleDisplayMode(.inline)
4851 .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 }
5857 }
58 }
5959
60 ToolbarItemGroup(placement: .topBarTrailing) {
6061 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 }
6788
68 if isOwnedByCurrentUser {
69 trackerActionsMenu
89 if isOwnedByCurrentUser {
90 trackerActionsMenu
91 }
7092 }
7193 }
7294 }
@@ -130,12 +152,37 @@ struct TicketListView: View {
130152 .presentationDetents([.large])
131153 }
132154 }
133 .sheet(isPresented: $showTrackerLabels) {
155 .sheet(isPresented: $showTrackerLabels, onDismiss: syncTrackerLabelsIntoTicketList) {
134156 if let trackerManagementViewModel {
135157 TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
136158 .presentationDetents([.large])
137159 }
138160 }
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 }
139186 .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
140187 Button("Cancel", role: .cancel) {
141188 // no-op: .cancel role handles alert dismissal
@@ -153,6 +200,13 @@ struct TicketListView: View {
153200 } message: {
154201 Text("“\(tracker.name)” will be permanently deleted.")
155202 }
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 }
156210 .navigationDestination(isPresented: Binding(
157211 get: { createdTicket != nil },
158212 set: { isPresented in
@@ -199,6 +253,8 @@ struct TicketListView: View {
199253 savedFilters: viewModel.savedFilters,
200254 activeSavedFilterID: viewModel.activeSavedFilterID,
201255 canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
256 isSelectionMode: viewModel.isSelectionMode,
257 selectedTicketCount: viewModel.selectedTicketCount,
202258 filter: $vm.filter
203259 ) {
204260 showLabelFilterSheet = true
@@ -216,26 +272,40 @@ struct TicketListView: View {
216272 .listRowSeparator(.hidden)
217273
218274 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 }
239309 }
240310 }
241311 .task {
@@ -287,6 +357,20 @@ struct TicketListView: View {
287357 .refreshable {
288358 await viewModel.loadTickets()
289359 }
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 }
290374 }
291375
292376 private func emptyStateDescription(for viewModel: TicketListViewModel) -> String {
@@ -296,6 +380,11 @@ struct TicketListView: View {
296380 return "No \(viewModel.filter.rawValue.lowercased()) tickets found."
297381 }
298382
383 private func syncTrackerLabelsIntoTicketList() {
384 guard let trackerManagementViewModel, let viewModel else { return }
385 viewModel.syncTrackerLabels(trackerManagementViewModel.labels)
386 }
387
299388 private var trackerActionsMenu: some View {
300389 Menu {
301390 Button {
@@ -535,6 +624,8 @@ private struct TicketListFilterHeader: View {
535624 let savedFilters: [SavedTicketFilter]
536625 let activeSavedFilterID: SavedTicketFilter.ID?
537626 let canSaveCurrentFilter: Bool
627 let isSelectionMode: Bool
628 let selectedTicketCount: Int
538629 @Binding var filter: TicketFilter
539630 let onShowLabels: () -> Void
540631 let onSaveFilter: () -> Void
@@ -544,12 +635,19 @@ private struct TicketListFilterHeader: View {
544635
545636 var body: some View {
546637 VStack(alignment: .leading, spacing: 10) {
638 if isSelectionMode {
639 Text(selectionSummary)
640 .font(.subheadline.weight(.medium))
641 .foregroundStyle(.secondary)
642 }
643
547644 Picker("Filter", selection: $filter) {
548645 ForEach(TicketFilter.allCases, id: \.self) { filter in
549646 Text(filter.rawValue).tag(filter)
550647 }
551648 }
552649 .pickerStyle(.segmented)
650 .disabled(isSelectionMode)
553651
554652 TicketQuickFilterBar(
555653 selectedLabels: selectedLabels,
@@ -562,11 +660,16 @@ private struct TicketListFilterHeader: View {
562660 onApplySavedFilter: onApplySavedFilter,
563661 onDeleteSavedFilter: onDeleteSavedFilter
564662 )
663 .disabled(isSelectionMode)
565664 }
566665 .padding(.horizontal, 16)
567666 .padding(.top, 6)
568667 .padding(.bottom, 10)
569668 }
669
670 private var selectionSummary: String {
671 selectedTicketCount == 0 ? "Select tickets for bulk actions." : "\(selectedTicketCount) selected"
672 }
570673}
571674
572675private struct TicketQuickFilterBar: View {
@@ -810,6 +913,181 @@ private struct TicketListLabelToggleRow: View {
810913 }
811914}
812915
916private 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
958private 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
1010private 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
1073private 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
8131091// MARK: - Ticket Row
8141092
8151093private struct TicketRowView: View {
Hutch/Views/Tickets/TicketListViewModel.swift +258 −1
@@ -53,6 +53,14 @@ private struct MutationEventRef: Decodable, Sendable {
5353 let eventType: String
5454}
5555
56private struct TicketListUserLookupResponse: Decodable, Sendable {
57 let user: TicketListUserIDPayload?
58}
59
60private struct TicketListUserIDPayload: Decodable, Sendable {
61 let id: Int
62}
63
5664// MARK: - Filter
5765
5866enum TicketFilter: String, CaseIterable, Codable, Sendable {
@@ -78,6 +86,8 @@ final class TicketListViewModel {
7886 private(set) var isPerformingAction = false
7987 private(set) var trackerLabels: [TicketLabel] = []
8088 private(set) var savedFilters: [SavedTicketFilter]
89 private(set) var isSelectionMode = false
90 private(set) var selectedTicketIDs: Set<Int> = []
8191 var error: String?
8292 var filter: TicketFilter = .open {
8393 didSet {
@@ -198,6 +208,12 @@ final class TicketListViewModel {
198208 }
199209 """
200210
211 private static let userLookupQuery = """
212 query userLookup($username: String!) {
213 user(username: $username) { id }
214 }
215 """
216
201217 // MARK: - Computed
202218
203219 var currentFilterState: TicketListFilterState {
@@ -216,6 +232,14 @@ final class TicketListViewModel {
216232 availableLabels.filter { selectedLabelIDs.contains($0.id) }
217233 }
218234
235 var selectedTickets: [TicketSummary] {
236 tickets.filter { selectedTicketIDs.contains($0.id) }
237 }
238
239 var selectedTicketCount: Int {
240 selectedTicketIDs.count
241 }
242
219243 var suggestedSavedFilterName: String {
220244 let labelNames = selectedLabels.map(\.name).sorted()
221245 var components: [String] = []
@@ -252,6 +276,7 @@ final class TicketListViewModel {
252276 tickets = page.results
253277 cursor = page.cursor
254278 hasMore = page.cursor != nil
279 reconcileSelectionWithLoadedTickets()
255280 } catch {
256281 self.error = error.userFacingMessage
257282 }
@@ -274,6 +299,7 @@ final class TicketListViewModel {
274299 tickets.append(contentsOf: page.results)
275300 cursor = page.cursor
276301 hasMore = page.cursor != nil
302 reconcileSelectionWithLoadedTickets()
277303 } catch {
278304 self.error = error.userFacingMessage
279305 }
@@ -427,12 +453,18 @@ final class TicketListViewModel {
427453 variables: ["rid": trackerRid],
428454 responseType: TrackerLabelsResponse.self
429455 )
430 trackerLabels = result.tracker.labels.results
456 syncTrackerLabels(result.tracker.labels.results)
431457 } catch {
432458 self.error = error.userFacingMessage
433459 }
434460 }
435461
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
436468 func toggleLabelSelection(_ label: TicketLabel) {
437469 if selectedLabelIDs.contains(label.id) {
438470 selectedLabelIDs.remove(label.id)
@@ -445,6 +477,29 @@ final class TicketListViewModel {
445477 selectedLabelIDs = []
446478 }
447479
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
448503 func resetFilters() {
449504 filter = .open
450505 selectedLabelIDs = []
@@ -564,6 +619,72 @@ final class TicketListViewModel {
564619 tickets.first(where: { $0.id == ticketId })
565620 }
566621
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
567688 // MARK: - Private
568689
569690 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
@@ -655,6 +776,137 @@ final class TicketListViewModel {
655776 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id
656777 }
657778
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
658910 static func filterTickets(
659911 _ tickets: [TicketSummary],
660912 state: TicketListFilterState,
@@ -690,3 +942,8 @@ final class TicketListViewModel {
690942 }
691943 }
692944}
945
946private 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 {
10691069 var body: some View {
10701070 NavigationStack {
10711071 List {
1072 Section {
1073 Text("Labels are managed here and reused throughout the tracker.")
1074 .font(.footnote)
1075 .foregroundStyle(.secondary)
1076 }
1077
10721078 if viewModel.isLoadingLabels {
10731079 HStack {
10741080 Spacer()
@@ -1083,10 +1089,12 @@ struct TrackerLabelManagementSheet: View {
10831089 )
10841090 } else {
10851091 ForEach(viewModel.labels) { label in
1086 HStack {
1087 LabelPill(label: label)
1088 Spacer()
1092 Button {
1093 editingLabel = label
1094 } label: {
1095 TrackerLabelManagementRow(label: label)
10891096 }
1097 .buttonStyle(.plain)
10901098 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
10911099 Button {
10921100 editingLabel = label
@@ -1178,6 +1186,44 @@ struct TrackerLabelManagementSheet: View {
11781186 }
11791187}
11801188
1189private 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
11811227private struct TrackerLabelEditorSheet: View {
11821228 let title: String
11831229 let submitTitle: String
HutchTests/TicketBulkActionTests.swift added +35
@@ -0,0 +1,35 @@
1import Testing
2@testable import Hutch
3
4struct 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
22import Testing
33@testable import Hutch
44
5@MainActor
56struct TicketListViewModelTests {
67
78 @Test
@@ -81,6 +82,43 @@ struct TicketListViewModelTests {
8182 #expect(filtered.map(\.id) == [2])
8283 }
8384
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
84122 @Test
85123 @MainActor
86124 func resolveTicketInputHasCorrectStatusAndDefaultResolution() {