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 @@
515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 515 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 516 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
517 CODE_SIGN_STYLE = Automatic; 517 CODE_SIGN_STYLE = Automatic;
518 CURRENT_PROJECT_VERSION = 45; 518 CURRENT_PROJECT_VERSION = 46;
519 DEVELOPMENT_TEAM = ZCNAX3VL9D; 519 DEVELOPMENT_TEAM = ZCNAX3VL9D;
520 ENABLE_PREVIEWS = YES; 520 ENABLE_PREVIEWS = YES;
521 GENERATE_INFOPLIST_FILE = YES; 521 GENERATE_INFOPLIST_FILE = YES;
@@ -532,7 +532,7 @@
532 "$(inherited)", 532 "$(inherited)",
533 "@executable_path/Frameworks", 533 "@executable_path/Frameworks",
534 ); 534 );
535 MARKETING_VERSION = 2.19.2; 535 MARKETING_VERSION = 2.19.3;
536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 536 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
537 PRODUCT_NAME = "$(TARGET_NAME)"; 537 PRODUCT_NAME = "$(TARGET_NAME)";
538 STRING_CATALOG_GENERATE_SYMBOLS = YES; 538 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -552,7 +552,7 @@
552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 552 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 553 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
554 CODE_SIGN_STYLE = Automatic; 554 CODE_SIGN_STYLE = Automatic;
555 CURRENT_PROJECT_VERSION = 45; 555 CURRENT_PROJECT_VERSION = 46;
556 DEVELOPMENT_TEAM = ZCNAX3VL9D; 556 DEVELOPMENT_TEAM = ZCNAX3VL9D;
557 ENABLE_PREVIEWS = YES; 557 ENABLE_PREVIEWS = YES;
558 GENERATE_INFOPLIST_FILE = YES; 558 GENERATE_INFOPLIST_FILE = YES;
@@ -569,7 +569,7 @@
569 "$(inherited)", 569 "$(inherited)",
570 "@executable_path/Frameworks", 570 "@executable_path/Frameworks",
571 ); 571 );
572 MARKETING_VERSION = 2.19.2; 572 MARKETING_VERSION = 2.19.3;
573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 573 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
574 PRODUCT_NAME = "$(TARGET_NAME)"; 574 PRODUCT_NAME = "$(TARGET_NAME)";
575 STRING_CATALOG_GENERATE_SYMBOLS = YES; 575 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -632,7 +632,7 @@
632 APPLICATION_EXTENSION_API_ONLY = YES; 632 APPLICATION_EXTENSION_API_ONLY = YES;
633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 633 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
634 CODE_SIGN_STYLE = Automatic; 634 CODE_SIGN_STYLE = Automatic;
635 CURRENT_PROJECT_VERSION = 45; 635 CURRENT_PROJECT_VERSION = 46;
636 DEVELOPMENT_TEAM = ZCNAX3VL9D; 636 DEVELOPMENT_TEAM = ZCNAX3VL9D;
637 GENERATE_INFOPLIST_FILE = NO; 637 GENERATE_INFOPLIST_FILE = NO;
638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 638 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -642,7 +642,7 @@
642 "@executable_path/Frameworks", 642 "@executable_path/Frameworks",
643 "@executable_path/../../Frameworks", 643 "@executable_path/../../Frameworks",
644 ); 644 );
645 MARKETING_VERSION = 2.19.2; 645 MARKETING_VERSION = 2.19.3;
646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 646 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
647 PRODUCT_NAME = "$(TARGET_NAME)"; 647 PRODUCT_NAME = "$(TARGET_NAME)";
648 SKIP_INSTALL = YES; 648 SKIP_INSTALL = YES;
@@ -661,7 +661,7 @@
661 APPLICATION_EXTENSION_API_ONLY = YES; 661 APPLICATION_EXTENSION_API_ONLY = YES;
662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 662 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
663 CODE_SIGN_STYLE = Automatic; 663 CODE_SIGN_STYLE = Automatic;
664 CURRENT_PROJECT_VERSION = 45; 664 CURRENT_PROJECT_VERSION = 46;
665 DEVELOPMENT_TEAM = ZCNAX3VL9D; 665 DEVELOPMENT_TEAM = ZCNAX3VL9D;
666 GENERATE_INFOPLIST_FILE = NO; 666 GENERATE_INFOPLIST_FILE = NO;
667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 667 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -671,7 +671,7 @@
671 "@executable_path/Frameworks", 671 "@executable_path/Frameworks",
672 "@executable_path/../../Frameworks", 672 "@executable_path/../../Frameworks",
673 ); 673 );
674 MARKETING_VERSION = 2.19.2; 674 MARKETING_VERSION = 2.19.3;
675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 675 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
676 PRODUCT_NAME = "$(TARGET_NAME)"; 676 PRODUCT_NAME = "$(TARGET_NAME)";
677 SKIP_INSTALL = YES; 677 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 {
19 @State private var showTrackerACLs = false 19 @State private var showTrackerACLs = false
20 @State private var showTrackerLabels = false 20 @State private var showTrackerLabels = false
21 @State private var showDeleteTrackerConfirmation = false 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 private var isOwnedByCurrentUser: Bool { 26 private var isOwnedByCurrentUser: Bool {
24 guard let currentUser = appState.currentUser else { return false } 27 guard let currentUser = appState.currentUser else { return false }
@@ -46,27 +49,46 @@ struct TicketListView: View {
46 .navigationTitle(tracker.name) 49 .navigationTitle(tracker.name)
47 .navigationBarTitleDisplayMode(.inline) 50 .navigationBarTitleDisplayMode(.inline)
48 .toolbar { 51 .toolbar {
49 ToolbarItemGroup(placement: .topBarTrailing) { 52 ToolbarItem(placement: .topBarLeading) {
50 SRHTShareButton( 53 if let viewModel, viewModel.isSelectionMode {
51 url: SRHTWebURL.tracker( 54 Button("Cancel") {
52 ownerUsername: String(tracker.owner.canonicalName.dropFirst()), 55 viewModel.setSelectionMode(false)
53 trackerName: tracker.name 56 }
54 ),
55 target: .tracker
56 ) {
57 Image(systemName: "square.and.arrow.up")
58 } 57 }
58 }
59 59
60 ToolbarItemGroup(placement: .topBarTrailing) {
60 if viewModel != nil { 61 if viewModel != nil {
61 Button { 62 if let viewModel, viewModel.isSelectionMode {
62 showCreateTicketSheet = true 63 Button("All") {
63 } label: { 64 viewModel.selectVisibleTickets(viewModel.filteredTickets)
64 Image(systemName: "plus") 65 }
65 } 66 .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction)
66 .accessibilityLabel("Create ticket") 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 { 89 if isOwnedByCurrentUser {
69 trackerActionsMenu 90 trackerActionsMenu
91 }
70 } 92 }
71 } 93 }
72 } 94 }
@@ -130,12 +152,37 @@ struct TicketListView: View {
130 .presentationDetents([.large]) 152 .presentationDetents([.large])
131 } 153 }
132 } 154 }
133 .sheet(isPresented: $showTrackerLabels) { 155 .sheet(isPresented: $showTrackerLabels, onDismiss: syncTrackerLabelsIntoTicketList) {
134 if let trackerManagementViewModel { 156 if let trackerManagementViewModel {
135 TrackerLabelManagementSheet(viewModel: trackerManagementViewModel) 157 TrackerLabelManagementSheet(viewModel: trackerManagementViewModel)
136 .presentationDetents([.large]) 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 .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) { 186 .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) {
140 Button("Cancel", role: .cancel) { 187 Button("Cancel", role: .cancel) {
141 // no-op: .cancel role handles alert dismissal 188 // no-op: .cancel role handles alert dismissal
@@ -153,6 +200,13 @@ struct TicketListView: View {
153 } message: { 200 } message: {
154 Text("“\(tracker.name)” will be permanently deleted.") 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 .navigationDestination(isPresented: Binding( 210 .navigationDestination(isPresented: Binding(
157 get: { createdTicket != nil }, 211 get: { createdTicket != nil },
158 set: { isPresented in 212 set: { isPresented in
@@ -199,6 +253,8 @@ struct TicketListView: View {
199 savedFilters: viewModel.savedFilters, 253 savedFilters: viewModel.savedFilters,
200 activeSavedFilterID: viewModel.activeSavedFilterID, 254 activeSavedFilterID: viewModel.activeSavedFilterID,
201 canSaveCurrentFilter: viewModel.hasCustomFilterSelection, 255 canSaveCurrentFilter: viewModel.hasCustomFilterSelection,
256 isSelectionMode: viewModel.isSelectionMode,
257 selectedTicketCount: viewModel.selectedTicketCount,
202 filter: $vm.filter 258 filter: $vm.filter
203 ) { 259 ) {
204 showLabelFilterSheet = true 260 showLabelFilterSheet = true
@@ -216,26 +272,40 @@ struct TicketListView: View {
216 .listRowSeparator(.hidden) 272 .listRowSeparator(.hidden)
217 273
218 ForEach(viewModel.filteredTickets) { ticket in 274 ForEach(viewModel.filteredTickets) { ticket in
219 NavigationLink { 275 Group {
220 TicketDetailView( 276 if viewModel.isSelectionMode {
221 ownerUsername: String(tracker.owner.canonicalName.dropFirst()), 277 Button {
222 trackerName: tracker.name, 278 viewModel.toggleTicketSelection(ticket)
223 trackerId: tracker.id, 279 } label: {
224 trackerRid: tracker.rid, 280 SelectableTicketRow(
225 ticketId: ticket.id 281 ticket: ticket,
226 ) 282 isSelected: viewModel.selectedTicketIDs.contains(ticket.id)
227 } label: { 283 )
228 TicketRowView(ticket: ticket) 284 }
229 } 285 .buttonStyle(.plain)
230 .swipeActions(edge: .leading, allowsFullSwipe: true) { 286 } else {
231 if swipeActionsEnabled { 287 NavigationLink {
232 ticketAssignSwipeAction(ticket, viewModel: viewModel) 288 TicketDetailView(
233 } 289 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
234 } 290 trackerName: tracker.name,
235 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 291 trackerId: tracker.id,
236 if swipeActionsEnabled { 292 trackerRid: tracker.rid,
237 ticketStatusSwipeAction(ticket, viewModel: viewModel) 293 ticketId: ticket.id
238 ticketLabelSwipeAction(ticket, viewModel: viewModel) 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 .task { 311 .task {
@@ -287,6 +357,20 @@ struct TicketListView: View {
287 .refreshable { 357 .refreshable {
288 await viewModel.loadTickets() 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 private func emptyStateDescription(for viewModel: TicketListViewModel) -> String { 376 private func emptyStateDescription(for viewModel: TicketListViewModel) -> String {
@@ -296,6 +380,11 @@ struct TicketListView: View {
296 return "No \(viewModel.filter.rawValue.lowercased()) tickets found." 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 private var trackerActionsMenu: some View { 388 private var trackerActionsMenu: some View {
300 Menu { 389 Menu {
301 Button { 390 Button {
@@ -535,6 +624,8 @@ private struct TicketListFilterHeader: View {
535 let savedFilters: [SavedTicketFilter] 624 let savedFilters: [SavedTicketFilter]
536 let activeSavedFilterID: SavedTicketFilter.ID? 625 let activeSavedFilterID: SavedTicketFilter.ID?
537 let canSaveCurrentFilter: Bool 626 let canSaveCurrentFilter: Bool
627 let isSelectionMode: Bool
628 let selectedTicketCount: Int
538 @Binding var filter: TicketFilter 629 @Binding var filter: TicketFilter
539 let onShowLabels: () -> Void 630 let onShowLabels: () -> Void
540 let onSaveFilter: () -> Void 631 let onSaveFilter: () -> Void
@@ -544,12 +635,19 @@ private struct TicketListFilterHeader: View {
544 635
545 var body: some View { 636 var body: some View {
546 VStack(alignment: .leading, spacing: 10) { 637 VStack(alignment: .leading, spacing: 10) {
638 if isSelectionMode {
639 Text(selectionSummary)
640 .font(.subheadline.weight(.medium))
641 .foregroundStyle(.secondary)
642 }
643
547 Picker("Filter", selection: $filter) { 644 Picker("Filter", selection: $filter) {
548 ForEach(TicketFilter.allCases, id: \.self) { filter in 645 ForEach(TicketFilter.allCases, id: \.self) { filter in
549 Text(filter.rawValue).tag(filter) 646 Text(filter.rawValue).tag(filter)
550 } 647 }
551 } 648 }
552 .pickerStyle(.segmented) 649 .pickerStyle(.segmented)
650 .disabled(isSelectionMode)
553 651
554 TicketQuickFilterBar( 652 TicketQuickFilterBar(
555 selectedLabels: selectedLabels, 653 selectedLabels: selectedLabels,
@@ -562,11 +660,16 @@ private struct TicketListFilterHeader: View {
562 onApplySavedFilter: onApplySavedFilter, 660 onApplySavedFilter: onApplySavedFilter,
563 onDeleteSavedFilter: onDeleteSavedFilter 661 onDeleteSavedFilter: onDeleteSavedFilter
564 ) 662 )
663 .disabled(isSelectionMode)
565 } 664 }
566 .padding(.horizontal, 16) 665 .padding(.horizontal, 16)
567 .padding(.top, 6) 666 .padding(.top, 6)
568 .padding(.bottom, 10) 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
572private struct TicketQuickFilterBar: View { 675private struct TicketQuickFilterBar: View {
@@ -810,6 +913,181 @@ private struct TicketListLabelToggleRow: View {
810 } 913 }
811} 914}
812 915
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
813// MARK: - Ticket Row 1091// MARK: - Ticket Row
814 1092
815private struct TicketRowView: View { 1093private struct TicketRowView: View {
Hutch/Views/Tickets/TicketListViewModel.swift +258 −1
@@ -53,6 +53,14 @@ private struct MutationEventRef: Decodable, Sendable {
53 let eventType: String 53 let eventType: String
54} 54}
55 55
56private struct TicketListUserLookupResponse: Decodable, Sendable {
57 let user: TicketListUserIDPayload?
58}
59
60private struct TicketListUserIDPayload: Decodable, Sendable {
61 let id: Int
62}
63
56// MARK: - Filter 64// MARK: - Filter
57 65
58enum TicketFilter: String, CaseIterable, Codable, Sendable { 66enum TicketFilter: String, CaseIterable, Codable, Sendable {
@@ -78,6 +86,8 @@ final class TicketListViewModel {
78 private(set) var isPerformingAction = false 86 private(set) var isPerformingAction = false
79 private(set) var trackerLabels: [TicketLabel] = [] 87 private(set) var trackerLabels: [TicketLabel] = []
80 private(set) var savedFilters: [SavedTicketFilter] 88 private(set) var savedFilters: [SavedTicketFilter]
89 private(set) var isSelectionMode = false
90 private(set) var selectedTicketIDs: Set<Int> = []
81 var error: String? 91 var error: String?
82 var filter: TicketFilter = .open { 92 var filter: TicketFilter = .open {
83 didSet { 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 // MARK: - Computed 217 // MARK: - Computed
202 218
203 var currentFilterState: TicketListFilterState { 219 var currentFilterState: TicketListFilterState {
@@ -216,6 +232,14 @@ final class TicketListViewModel {
216 availableLabels.filter { selectedLabelIDs.contains($0.id) } 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 var suggestedSavedFilterName: String { 243 var suggestedSavedFilterName: String {
220 let labelNames = selectedLabels.map(\.name).sorted() 244 let labelNames = selectedLabels.map(\.name).sorted()
221 var components: [String] = [] 245 var components: [String] = []
@@ -252,6 +276,7 @@ final class TicketListViewModel {
252 tickets = page.results 276 tickets = page.results
253 cursor = page.cursor 277 cursor = page.cursor
254 hasMore = page.cursor != nil 278 hasMore = page.cursor != nil
279 reconcileSelectionWithLoadedTickets()
255 } catch { 280 } catch {
256 self.error = error.userFacingMessage 281 self.error = error.userFacingMessage
257 } 282 }
@@ -274,6 +299,7 @@ final class TicketListViewModel {
274 tickets.append(contentsOf: page.results) 299 tickets.append(contentsOf: page.results)
275 cursor = page.cursor 300 cursor = page.cursor
276 hasMore = page.cursor != nil 301 hasMore = page.cursor != nil
302 reconcileSelectionWithLoadedTickets()
277 } catch { 303 } catch {
278 self.error = error.userFacingMessage 304 self.error = error.userFacingMessage
279 } 305 }
@@ -427,12 +453,18 @@ final class TicketListViewModel {
427 variables: ["rid": trackerRid], 453 variables: ["rid": trackerRid],
428 responseType: TrackerLabelsResponse.self 454 responseType: TrackerLabelsResponse.self
429 ) 455 )
430 trackerLabels = result.tracker.labels.results 456 syncTrackerLabels(result.tracker.labels.results)
431 } catch { 457 } catch {
432 self.error = error.userFacingMessage 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 func toggleLabelSelection(_ label: TicketLabel) { 468 func toggleLabelSelection(_ label: TicketLabel) {
437 if selectedLabelIDs.contains(label.id) { 469 if selectedLabelIDs.contains(label.id) {
438 selectedLabelIDs.remove(label.id) 470 selectedLabelIDs.remove(label.id)
@@ -445,6 +477,29 @@ final class TicketListViewModel {
445 selectedLabelIDs = [] 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 func resetFilters() { 503 func resetFilters() {
449 filter = .open 504 filter = .open
450 selectedLabelIDs = [] 505 selectedLabelIDs = []
@@ -564,6 +619,72 @@ final class TicketListViewModel {
564 tickets.first(where: { $0.id == ticketId }) 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 // MARK: - Private 688 // MARK: - Private
568 689
569 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async { 690 private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async {
@@ -655,6 +776,137 @@ final class TicketListViewModel {
655 activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id 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 static func filterTickets( 910 static func filterTickets(
659 _ tickets: [TicketSummary], 911 _ tickets: [TicketSummary],
660 state: TicketListFilterState, 912 state: TicketListFilterState,
@@ -690,3 +942,8 @@ final class TicketListViewModel {
690 } 942 }
691 } 943 }
692} 944}
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 {
1069 var body: some View { 1069 var body: some View {
1070 NavigationStack { 1070 NavigationStack {
1071 List { 1071 List {
1072 Section {
1073 Text("Labels are managed here and reused throughout the tracker.")
1074 .font(.footnote)
1075 .foregroundStyle(.secondary)
1076 }
1077
1072 if viewModel.isLoadingLabels { 1078 if viewModel.isLoadingLabels {
1073 HStack { 1079 HStack {
1074 Spacer() 1080 Spacer()
@@ -1083,10 +1089,12 @@ struct TrackerLabelManagementSheet: View {
1083 ) 1089 )
1084 } else { 1090 } else {
1085 ForEach(viewModel.labels) { label in 1091 ForEach(viewModel.labels) { label in
1086 HStack { 1092 Button {
1087 LabelPill(label: label) 1093 editingLabel = label
1088 Spacer() 1094 } label: {
1095 TrackerLabelManagementRow(label: label)
1089 } 1096 }
1097 .buttonStyle(.plain)
1090 .swipeActions(edge: .trailing, allowsFullSwipe: false) { 1098 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
1091 Button { 1099 Button {
1092 editingLabel = label 1100 editingLabel = label
@@ -1178,6 +1186,44 @@ struct TrackerLabelManagementSheet: View {
1178 } 1186 }
1179} 1187}
1180 1188
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
1181private struct TrackerLabelEditorSheet: View { 1227private struct TrackerLabelEditorSheet: View {
1182 let title: String 1228 let title: String
1183 let submitTitle: String 1229 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
2import Testing 2import Testing
3@testable import Hutch 3@testable import Hutch
4 4
5@MainActor
5struct TicketListViewModelTests { 6struct TicketListViewModelTests {
6 7
7 @Test 8 @Test
@@ -81,6 +82,43 @@ struct TicketListViewModelTests {
81 #expect(filtered.map(\.id) == [2]) 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 @Test 122 @Test
85 @MainActor 123 @MainActor
86 func resolveTicketInputHasCorrectStatusAndDefaultResolution() { 124 func resolveTicketInputHasCorrectStatusAndDefaultResolution() {