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 /Hutch/Views/Tickets/TicketListViewModel.swift | |
| 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
Diffstat (limited to 'Hutch/Views/Tickets/TicketListViewModel.swift')
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 259 |
1 files changed, 258 insertions, 1 deletions
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) +} |
