diff options
| author | Christian Cleberg <[email protected]> | 2026-04-12 22:42:33 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-12 22:42:33 -0500 |
| commit | 576bb6c2432a15f5138771c06019b55fd2244e15 (patch) | |
| tree | 167b4704658c11ea6b9e7170390098e13c89b94b | |
| parent | 54d037548261c1546555a89a6a803b542a769880 (diff) | |
| download | hutch-576bb6c2432a15f5138771c06019b55fd2244e15.tar.gz hutch-576bb6c2432a15f5138771c06019b55fd2244e15.tar.bz2 hutch-576bb6c2432a15f5138771c06019b55fd2244e15.zip | |
feat: add ticket label management and bulk actions
Implements: https://todo.sr.ht/~ccleberg/hutch/45
Implements: https://todo.sr.ht/~ccleberg/hutch/46
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/Models/TicketBulkAction.swift | 88 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 354 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 259 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerManagementView.swift | 52 | ||||
| -rw-r--r-- | HutchTests/TicketBulkActionTests.swift | 35 | ||||
| -rw-r--r-- | HutchTests/TicketListViewModelTests.swift | 38 |
7 files changed, 792 insertions, 50 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 3a7f7cd..c719ba2 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -515,7 +515,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -532,7 +532,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.19.2; + MARKETING_VERSION = 2.19.3; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -552,7 +552,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -569,7 +569,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.19.2; + MARKETING_VERSION = 2.19.3; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -632,7 +632,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -642,7 +642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.19.2; + MARKETING_VERSION = 2.19.3; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -661,7 +661,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 45; + CURRENT_PROJECT_VERSION = 46; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -671,7 +671,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.19.2; + MARKETING_VERSION = 2.19.3; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/Models/TicketBulkAction.swift b/Hutch/Models/TicketBulkAction.swift new file mode 100644 index 0000000..dcf7676 --- /dev/null +++ b/Hutch/Models/TicketBulkAction.swift @@ -0,0 +1,88 @@ +import Foundation + +enum TicketBulkActionKind: String, Sendable { + case close + case assign + + var displayName: String { + switch self { + case .close: + "Close" + case .assign: + "Assign" + } + } + + var pastTenseDisplayName: String { + switch self { + case .close: + "Closed" + case .assign: + "Assigned" + } + } +} + +struct TicketBulkActionResult: Identifiable, Sendable { + let id = UUID() + let action: TicketBulkActionKind + let totalCount: Int + let updatedCount: Int + let unchangedCount: Int + let failures: [TicketBulkActionFailure] + + var failedCount: Int { + failures.count + } + + var title: String { + if updatedCount == 0, failedCount > 0 { + return "\(action.displayName) Failed" + } + if failedCount > 0 { + return "\(action.displayName) Partially Applied" + } + return "\(action.displayName) Complete" + } + + var message: String { + var components: [String] = [] + + if updatedCount > 0 { + components.append("\(action.pastTenseDisplayName) \(updatedCount) \(ticketWord(for: updatedCount)).") + } + + if unchangedCount > 0 { + let unchangedDescription: String + switch action { + case .close: + unchangedDescription = "\(unchangedCount) already closed." + case .assign: + unchangedDescription = "\(unchangedCount) already assigned." + } + components.append(unchangedDescription) + } + + if failedCount > 0 { + let ids = failures + .map { "#\($0.ticketID)" } + .joined(separator: ", ") + components.append("Failed: \(ids).") + } + + if components.isEmpty { + components.append("No tickets were selected.") + } + + return components.joined(separator: " ") + } + + private func ticketWord(for count: Int) -> String { + count == 1 ? "ticket" : "tickets" + } +} + +struct TicketBulkActionFailure: Sendable { + let ticketID: Int + let message: String +} diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index 3e0c656..bd2236c 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -19,6 +19,9 @@ struct TicketListView: View { @State private var showTrackerACLs = false @State private var showTrackerLabels = false @State private var showDeleteTrackerConfirmation = false + @State private var showBulkCloseSheet = false + @State private var showBulkAssignSheet = false + @State private var bulkActionResult: TicketBulkActionResult? private var isOwnedByCurrentUser: Bool { guard let currentUser = appState.currentUser else { return false } @@ -46,27 +49,46 @@ struct TicketListView: View { .navigationTitle(tracker.name) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - SRHTShareButton( - url: SRHTWebURL.tracker( - ownerUsername: String(tracker.owner.canonicalName.dropFirst()), - trackerName: tracker.name - ), - target: .tracker - ) { - Image(systemName: "square.and.arrow.up") + ToolbarItem(placement: .topBarLeading) { + if let viewModel, viewModel.isSelectionMode { + Button("Cancel") { + viewModel.setSelectionMode(false) + } } + } + ToolbarItemGroup(placement: .topBarTrailing) { if viewModel != nil { - Button { - showCreateTicketSheet = true - } label: { - Image(systemName: "plus") - } - .accessibilityLabel("Create ticket") + if let viewModel, viewModel.isSelectionMode { + Button("All") { + viewModel.selectVisibleTickets(viewModel.filteredTickets) + } + .disabled(viewModel.filteredTickets.isEmpty || viewModel.isPerformingAction) + } else { + SRHTShareButton( + url: SRHTWebURL.tracker( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name + ), + target: .tracker + ) { + Image(systemName: "square.and.arrow.up") + } + + Button { + showCreateTicketSheet = true + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("Create ticket") + + Button("Select") { + viewModel?.setSelectionMode(true) + } - if isOwnedByCurrentUser { - trackerActionsMenu + if isOwnedByCurrentUser { + trackerActionsMenu + } } } } @@ -130,12 +152,37 @@ struct TicketListView: View { .presentationDetents([.large]) } } - .sheet(isPresented: $showTrackerLabels) { + .sheet(isPresented: $showTrackerLabels, onDismiss: syncTrackerLabelsIntoTicketList) { if let trackerManagementViewModel { TrackerLabelManagementSheet(viewModel: trackerManagementViewModel) .presentationDetents([.large]) } } + .sheet(isPresented: $showBulkCloseSheet) { + if let viewModel { + BulkResolveSheet( + viewModel: viewModel, + isPresented: $showBulkCloseSheet, + onComplete: { result in + bulkActionResult = result + } + ) + .presentationDetents([.medium]) + } + } + .sheet(isPresented: $showBulkAssignSheet) { + if let viewModel { + BulkAssignSheet( + viewModel: viewModel, + currentUser: appState.currentUser, + isPresented: $showBulkAssignSheet, + onComplete: { result in + bulkActionResult = result + } + ) + .presentationDetents([.medium]) + } + } .alert("Delete Tracker?", isPresented: $showDeleteTrackerConfirmation) { Button("Cancel", role: .cancel) { // no-op: .cancel role handles alert dismissal @@ -153,6 +200,13 @@ struct TicketListView: View { } message: { Text("“\(tracker.name)” will be permanently deleted.") } + .alert(item: $bulkActionResult) { result in + Alert( + title: Text(result.title), + message: Text(result.message), + dismissButton: .default(Text("OK")) + ) + } .navigationDestination(isPresented: Binding( get: { createdTicket != nil }, set: { isPresented in @@ -199,6 +253,8 @@ struct TicketListView: View { savedFilters: viewModel.savedFilters, activeSavedFilterID: viewModel.activeSavedFilterID, canSaveCurrentFilter: viewModel.hasCustomFilterSelection, + isSelectionMode: viewModel.isSelectionMode, + selectedTicketCount: viewModel.selectedTicketCount, filter: $vm.filter ) { showLabelFilterSheet = true @@ -216,26 +272,40 @@ struct TicketListView: View { .listRowSeparator(.hidden) ForEach(viewModel.filteredTickets) { ticket in - NavigationLink { - TicketDetailView( - ownerUsername: String(tracker.owner.canonicalName.dropFirst()), - trackerName: tracker.name, - trackerId: tracker.id, - trackerRid: tracker.rid, - ticketId: ticket.id - ) - } label: { - TicketRowView(ticket: ticket) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - if swipeActionsEnabled { - ticketAssignSwipeAction(ticket, viewModel: viewModel) - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if swipeActionsEnabled { - ticketStatusSwipeAction(ticket, viewModel: viewModel) - ticketLabelSwipeAction(ticket, viewModel: viewModel) + Group { + if viewModel.isSelectionMode { + Button { + viewModel.toggleTicketSelection(ticket) + } label: { + SelectableTicketRow( + ticket: ticket, + isSelected: viewModel.selectedTicketIDs.contains(ticket.id) + ) + } + .buttonStyle(.plain) + } else { + NavigationLink { + TicketDetailView( + ownerUsername: String(tracker.owner.canonicalName.dropFirst()), + trackerName: tracker.name, + trackerId: tracker.id, + trackerRid: tracker.rid, + ticketId: ticket.id + ) + } label: { + TicketRowView(ticket: ticket) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if swipeActionsEnabled { + ticketAssignSwipeAction(ticket, viewModel: viewModel) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if swipeActionsEnabled { + ticketStatusSwipeAction(ticket, viewModel: viewModel) + ticketLabelSwipeAction(ticket, viewModel: viewModel) + } + } } } .task { @@ -287,6 +357,20 @@ struct TicketListView: View { .refreshable { await viewModel.loadTickets() } + .safeAreaInset(edge: .bottom) { + if viewModel.isSelectionMode { + TicketBulkActionBar( + selectedCount: viewModel.selectedTicketCount, + isPerformingAction: viewModel.isPerformingAction, + onClose: { + showBulkCloseSheet = true + }, + onAssign: { + showBulkAssignSheet = true + } + ) + } + } } private func emptyStateDescription(for viewModel: TicketListViewModel) -> String { @@ -296,6 +380,11 @@ struct TicketListView: View { return "No \(viewModel.filter.rawValue.lowercased()) tickets found." } + private func syncTrackerLabelsIntoTicketList() { + guard let trackerManagementViewModel, let viewModel else { return } + viewModel.syncTrackerLabels(trackerManagementViewModel.labels) + } + private var trackerActionsMenu: some View { Menu { Button { @@ -535,6 +624,8 @@ private struct TicketListFilterHeader: View { let savedFilters: [SavedTicketFilter] let activeSavedFilterID: SavedTicketFilter.ID? let canSaveCurrentFilter: Bool + let isSelectionMode: Bool + let selectedTicketCount: Int @Binding var filter: TicketFilter let onShowLabels: () -> Void let onSaveFilter: () -> Void @@ -544,12 +635,19 @@ private struct TicketListFilterHeader: View { var body: some View { VStack(alignment: .leading, spacing: 10) { + if isSelectionMode { + Text(selectionSummary) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + Picker("Filter", selection: $filter) { ForEach(TicketFilter.allCases, id: \.self) { filter in Text(filter.rawValue).tag(filter) } } .pickerStyle(.segmented) + .disabled(isSelectionMode) TicketQuickFilterBar( selectedLabels: selectedLabels, @@ -562,11 +660,16 @@ private struct TicketListFilterHeader: View { onApplySavedFilter: onApplySavedFilter, onDeleteSavedFilter: onDeleteSavedFilter ) + .disabled(isSelectionMode) } .padding(.horizontal, 16) .padding(.top, 6) .padding(.bottom, 10) } + + private var selectionSummary: String { + selectedTicketCount == 0 ? "Select tickets for bulk actions." : "\(selectedTicketCount) selected" + } } private struct TicketQuickFilterBar: View { @@ -810,6 +913,181 @@ private struct TicketListLabelToggleRow: View { } } +private struct TicketBulkActionBar: View { + let selectedCount: Int + let isPerformingAction: Bool + let onClose: () -> Void + let onAssign: () -> Void + + var body: some View { + VStack(spacing: 12) { + HStack { + Text(selectedCount == 0 ? "Select tickets to continue" : "\(selectedCount) selected") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + Spacer() + } + + HStack(spacing: 12) { + Button("Close") { + onClose() + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(selectedCount == 0 || isPerformingAction) + + Button("Assign") { + onAssign() + } + .buttonStyle(.bordered) + .disabled(selectedCount == 0 || isPerformingAction) + + if isPerformingAction { + ProgressView() + .controlSize(.small) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 16) + .background(.ultraThinMaterial) + } +} + +private struct BulkResolveSheet: View { + let viewModel: TicketListViewModel + @Binding var isPresented: Bool + let onComplete: (TicketBulkActionResult) -> Void + @State private var selectedResolution: TicketResolution = .fixed + + private static let resolutionOptions: [TicketResolution] = [ + .closed, .fixed, .implemented, .wontFix, + .byDesign, .invalid, .duplicate, .notOurBug + ] + + var body: some View { + NavigationStack { + Form { + Section { + Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Section("Resolution") { + Picker("Resolution", selection: $selectedResolution) { + ForEach(Self.resolutionOptions, id: \.self) { resolution in + Text(resolution.displayName).tag(resolution) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + } + .navigationTitle("Close Tickets") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isPresented = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Close") { + Task { + if let result = await viewModel.closeSelectedTickets(resolution: selectedResolution) { + onComplete(result) + isPresented = false + } + } + } + .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction) + } + } + } + } +} + +private struct BulkAssignSheet: View { + let viewModel: TicketListViewModel + let currentUser: User? + @Binding var isPresented: Bool + let onComplete: (TicketBulkActionResult) -> Void + @State private var username = "" + + var body: some View { + NavigationStack { + Form { + Section { + Text("\(viewModel.selectedTicketCount) ticket\(viewModel.selectedTicketCount == 1 ? "" : "s") selected") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if let currentUser { + Section { + Button("Assign to Me") { + Task { + if let result = await viewModel.assignSelectedTickets(username: currentUser.username) { + onComplete(result) + isPresented = false + } + } + } + .disabled(viewModel.selectedTicketCount == 0 || viewModel.isPerformingAction) + } + } + + Section("Assign User") { + TextField("Username or ~username", text: $username) + .textContentType(.username) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } + } + .navigationTitle("Assign Tickets") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { isPresented = false } + } + ToolbarItem(placement: .confirmationAction) { + Button("Assign") { + Task { + if let result = await viewModel.assignSelectedTickets(username: username) { + onComplete(result) + isPresented = false + } + } + } + .disabled( + username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || viewModel.selectedTicketCount == 0 + || viewModel.isPerformingAction + ) + } + } + } + } +} + +private struct SelectableTicketRow: View { + let ticket: TicketSummary + let isSelected: Bool + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .padding(.top, 4) + + TicketRowView(ticket: ticket) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) + } +} + // MARK: - Ticket Row private struct TicketRowView: View { diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift index 3b1310d..a2e7135 100644 --- a/Hutch/Views/Tickets/TicketListViewModel.swift +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -53,6 +53,14 @@ private struct MutationEventRef: Decodable, Sendable { let eventType: String } +private struct TicketListUserLookupResponse: Decodable, Sendable { + let user: TicketListUserIDPayload? +} + +private struct TicketListUserIDPayload: Decodable, Sendable { + let id: Int +} + // MARK: - Filter enum TicketFilter: String, CaseIterable, Codable, Sendable { @@ -78,6 +86,8 @@ final class TicketListViewModel { private(set) var isPerformingAction = false private(set) var trackerLabels: [TicketLabel] = [] private(set) var savedFilters: [SavedTicketFilter] + private(set) var isSelectionMode = false + private(set) var selectedTicketIDs: Set<Int> = [] var error: String? var filter: TicketFilter = .open { didSet { @@ -198,6 +208,12 @@ final class TicketListViewModel { } """ + private static let userLookupQuery = """ + query userLookup($username: String!) { + user(username: $username) { id } + } + """ + // MARK: - Computed var currentFilterState: TicketListFilterState { @@ -216,6 +232,14 @@ final class TicketListViewModel { availableLabels.filter { selectedLabelIDs.contains($0.id) } } + var selectedTickets: [TicketSummary] { + tickets.filter { selectedTicketIDs.contains($0.id) } + } + + var selectedTicketCount: Int { + selectedTicketIDs.count + } + var suggestedSavedFilterName: String { let labelNames = selectedLabels.map(\.name).sorted() var components: [String] = [] @@ -252,6 +276,7 @@ final class TicketListViewModel { tickets = page.results cursor = page.cursor hasMore = page.cursor != nil + reconcileSelectionWithLoadedTickets() } catch { self.error = error.userFacingMessage } @@ -274,6 +299,7 @@ final class TicketListViewModel { tickets.append(contentsOf: page.results) cursor = page.cursor hasMore = page.cursor != nil + reconcileSelectionWithLoadedTickets() } catch { self.error = error.userFacingMessage } @@ -427,12 +453,18 @@ final class TicketListViewModel { variables: ["rid": trackerRid], responseType: TrackerLabelsResponse.self ) - trackerLabels = result.tracker.labels.results + syncTrackerLabels(result.tracker.labels.results) } catch { self.error = error.userFacingMessage } } + func syncTrackerLabels(_ labels: [TicketLabel]) { + trackerLabels = labels.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + tickets = Self.synchronizeTickets(tickets, with: trackerLabels) + selectedLabelIDs = Self.reconciledSelectedLabelIDs(selectedLabelIDs, availableLabels: trackerLabels) + } + func toggleLabelSelection(_ label: TicketLabel) { if selectedLabelIDs.contains(label.id) { selectedLabelIDs.remove(label.id) @@ -445,6 +477,29 @@ final class TicketListViewModel { selectedLabelIDs = [] } + func setSelectionMode(_ enabled: Bool) { + isSelectionMode = enabled + if !enabled { + clearTicketSelection() + } + } + + func toggleTicketSelection(_ ticket: TicketSummary) { + if selectedTicketIDs.contains(ticket.id) { + selectedTicketIDs.remove(ticket.id) + } else { + selectedTicketIDs.insert(ticket.id) + } + } + + func selectVisibleTickets(_ tickets: [TicketSummary]) { + selectedTicketIDs = Set(tickets.map(\.id)) + } + + func clearTicketSelection() { + selectedTicketIDs = [] + } + func resetFilters() { filter = .open selectedLabelIDs = [] @@ -564,6 +619,72 @@ final class TicketListViewModel { tickets.first(where: { $0.id == ticketId }) } + func closeSelectedTickets(resolution: TicketResolution) async -> TicketBulkActionResult? { + await performBulkAction( + kind: .close, + prepare: { ticket in + guard ticket.status != .resolved else { return .unchanged } + + let input = Self.bulkStatusUpdateInput(resolution: resolution) + let updatedTicket = updatedTicket(from: ticket, input: input) + return .request(updatedTicket: updatedTicket) { + try await self.executeBulkStatusUpdate(ticketID: ticket.id, input: input) + } + } + ) + } + + func assignSelectedTickets(username: String) async -> TicketBulkActionResult? { + let normalizedUsername = Self.normalizedUsername(username) + guard !normalizedUsername.isEmpty else { + error = "Enter a SourceHut username." + return nil + } + + do { + let userResult = try await client.execute( + service: .todo, + query: Self.userLookupQuery, + variables: ["username": normalizedUsername], + responseType: TicketListUserLookupResponse.self + ) + guard let userID = userResult.user?.id else { + error = "That user couldn’t be found." + return nil + } + + let assignee = Entity(canonicalName: Self.normalizedCanonicalName(normalizedUsername)) + return await performBulkAction( + kind: .assign, + prepare: { ticket in + guard !ticket.assignees.contains(where: { + Self.normalizedCanonicalName($0.canonicalName) == assignee.canonicalName + }) else { + return .unchanged + } + + let updatedTicket = TicketSummary( + id: ticket.id, + title: ticket.title, + status: ticket.status, + resolution: ticket.resolution, + created: ticket.created, + submitter: ticket.submitter, + labels: ticket.labels, + assignees: ticket.assignees + [assignee] + ) + + return .request(updatedTicket: updatedTicket) { + try await self.executeBulkAssign(ticketID: ticket.id, userID: userID) + } + } + ) + } catch { + self.error = error.userFacingMessage + return nil + } + } + // MARK: - Private private func performStatusUpdate(ticket: TicketSummary, input: [String: any Sendable]) async { @@ -655,6 +776,137 @@ final class TicketListViewModel { activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id } + private func reconcileSelectionWithLoadedTickets() { + let loadedTicketIDs = Set(tickets.map(\.id)) + selectedTicketIDs.formIntersection(loadedTicketIDs) + if isSelectionMode, selectedTicketIDs.isEmpty { + isSelectionMode = false + } + } + + private func performBulkAction( + kind: TicketBulkActionKind, + prepare: (TicketSummary) -> TicketBulkTicketOperation + ) async -> TicketBulkActionResult? { + guard !isPerformingAction else { return nil } + + let selected = selectedTickets + guard !selected.isEmpty else { return nil } + + isPerformingAction = true + error = nil + + var updatedCount = 0 + var unchangedCount = 0 + var failures: [TicketBulkActionFailure] = [] + var failedTicketIDs = Set<Int>() + + for ticket in selected { + switch prepare(ticket) { + case .unchanged: + unchangedCount += 1 + case .request(let updatedTicket, let request): + replaceTicket(updatedTicket) + do { + try await request() + updatedCount += 1 + } catch { + replaceTicket(ticket) + failedTicketIDs.insert(ticket.id) + failures.append( + TicketBulkActionFailure( + ticketID: ticket.id, + message: error.userFacingMessage + ) + ) + } + } + } + + isPerformingAction = false + + let result = TicketBulkActionResult( + action: kind, + totalCount: selected.count, + updatedCount: updatedCount, + unchangedCount: unchangedCount, + failures: failures + ) + + if failedTicketIDs.isEmpty { + clearTicketSelection() + isSelectionMode = false + } else { + selectedTicketIDs = failedTicketIDs + isSelectionMode = true + } + + return result + } + + private func replaceTicket(_ ticket: TicketSummary) { + guard let index = tickets.firstIndex(where: { $0.id == ticket.id }) else { return } + tickets[index] = ticket + } + + private func executeBulkStatusUpdate(ticketID: Int, input: [String: any Sendable]) async throws { + _ = try await client.execute( + service: .todo, + query: Self.updateStatusMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketID, + "input": input + ], + responseType: UpdateStatusResponse.self + ) + } + + private func executeBulkAssign(ticketID: Int, userID: Int) async throws { + _ = try await client.execute( + service: .todo, + query: Self.assignUserMutation, + variables: [ + "trackerId": trackerId, + "ticketId": ticketID, + "userId": userID + ], + responseType: AssignmentMutationResponse.self + ) + } + + private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] { + [ + "status": TicketStatus.resolved.rawValue, + "resolution": resolution.rawValue + ] + } + + static func synchronizeTickets(_ tickets: [TicketSummary], with labels: [TicketLabel]) -> [TicketSummary] { + let labelsByID = Dictionary(uniqueKeysWithValues: labels.map { ($0.id, $0) }) + + return tickets.map { ticket in + let updatedLabels = ticket.labels.compactMap { labelsByID[$0.id] } + return TicketSummary( + id: ticket.id, + title: ticket.title, + status: ticket.status, + resolution: ticket.resolution, + created: ticket.created, + submitter: ticket.submitter, + labels: updatedLabels, + assignees: ticket.assignees + ) + } + } + + static func reconciledSelectedLabelIDs( + _ selectedLabelIDs: Set<Int>, + availableLabels: [TicketLabel] + ) -> Set<Int> { + selectedLabelIDs.intersection(Set(availableLabels.map(\.id))) + } + static func filterTickets( _ tickets: [TicketSummary], state: TicketListFilterState, @@ -690,3 +942,8 @@ final class TicketListViewModel { } } } + +private enum TicketBulkTicketOperation { + case unchanged + case request(updatedTicket: TicketSummary, operation: @Sendable () async throws -> Void) +} diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift index 1542d54..cf923b6 100644 --- a/Hutch/Views/Tickets/TrackerManagementView.swift +++ b/Hutch/Views/Tickets/TrackerManagementView.swift @@ -1069,6 +1069,12 @@ struct TrackerLabelManagementSheet: View { var body: some View { NavigationStack { List { + Section { + Text("Labels are managed here and reused throughout the tracker.") + .font(.footnote) + .foregroundStyle(.secondary) + } + if viewModel.isLoadingLabels { HStack { Spacer() @@ -1083,10 +1089,12 @@ struct TrackerLabelManagementSheet: View { ) } else { ForEach(viewModel.labels) { label in - HStack { - LabelPill(label: label) - Spacer() + Button { + editingLabel = label + } label: { + TrackerLabelManagementRow(label: label) } + .buttonStyle(.plain) .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button { editingLabel = label @@ -1178,6 +1186,44 @@ struct TrackerLabelManagementSheet: View { } } +private struct TrackerLabelManagementRow: View { + let label: TicketLabel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + LabelPill(label: label) + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + } + + HStack(spacing: 12) { + colorSwatch(hex: label.backgroundColor, title: "Background") + colorSwatch(hex: label.foregroundColor, title: "Text") + } + } + .padding(.vertical, 4) + } + + private func colorSwatch(hex: String, title: String) -> some View { + HStack(spacing: 6) { + Circle() + .fill(Color(hex: hex) ?? .clear) + .frame(width: 10, height: 10) + .overlay { + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + } + + Text("\(title): \(hex.uppercased())") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + private struct TrackerLabelEditorSheet: View { let title: String let submitTitle: String diff --git a/HutchTests/TicketBulkActionTests.swift b/HutchTests/TicketBulkActionTests.swift new file mode 100644 index 0000000..2f4d1fd --- /dev/null +++ b/HutchTests/TicketBulkActionTests.swift @@ -0,0 +1,35 @@ +import Testing +@testable import Hutch + +struct TicketBulkActionTests { + + @Test + func bulkActionResultFormatsSuccessMessage() { + let result = TicketBulkActionResult( + action: .close, + totalCount: 3, + updatedCount: 3, + unchangedCount: 0, + failures: [] + ) + + #expect(result.title == "Close Complete") + #expect(result.message == "Closed 3 tickets.") + } + + @Test + func bulkActionResultFormatsPartialFailureMessage() { + let result = TicketBulkActionResult( + action: .assign, + totalCount: 4, + updatedCount: 2, + unchangedCount: 1, + failures: [ + TicketBulkActionFailure(ticketID: 42, message: "Network error") + ] + ) + + #expect(result.title == "Assign Partially Applied") + #expect(result.message == "Assigned 2 tickets. 1 already assigned. Failed: #42.") + } +} diff --git a/HutchTests/TicketListViewModelTests.swift b/HutchTests/TicketListViewModelTests.swift index 0f42bf8..a237071 100644 --- a/HutchTests/TicketListViewModelTests.swift +++ b/HutchTests/TicketListViewModelTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import Hutch +@MainActor struct TicketListViewModelTests { @Test @@ -82,6 +83,43 @@ struct TicketListViewModelTests { } @Test + func synchronizeTicketsReplacesEditedLabelsAndRemovesDeletedOnes() { + let existing = [ + makeTicket( + id: 7, + title: "Triage me", + status: .reported, + submitter: "~owner", + labels: [ + makeLabel(id: 1, name: "bug"), + makeLabel(id: 2, name: "stale") + ] + ) + ] + + let updated = TicketListViewModel.synchronizeTickets( + existing, + with: [makeLabel(id: 1, name: "bugfix")] + ) + + #expect(updated.first?.labels.map(\.id) == [1]) + #expect(updated.first?.labels.first?.name == "bugfix") + } + + @Test + func reconciledSelectedLabelIDsRemovesUnknownIDs() { + let reconciled = TicketListViewModel.reconciledSelectedLabelIDs( + [1, 2, 5], + availableLabels: [ + makeLabel(id: 2, name: "triage"), + makeLabel(id: 3, name: "qa") + ] + ) + + #expect(reconciled == [2]) + } + + @Test @MainActor func resolveTicketInputHasCorrectStatusAndDefaultResolution() { let input: [String: any Sendable] = [ |
