summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 22:42:33 -0500
committerChristian Cleberg <[email protected]>2026-04-12 22:42:33 -0500
commit576bb6c2432a15f5138771c06019b55fd2244e15 (patch)
tree167b4704658c11ea6b9e7170390098e13c89b94b /Hutch
parent54d037548261c1546555a89a6a803b542a769880 (diff)
downloadhutch-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')
-rw-r--r--Hutch/Models/TicketBulkAction.swift88
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift354
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift259
-rw-r--r--Hutch/Views/Tickets/TrackerManagementView.swift52
4 files changed, 711 insertions, 42 deletions
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