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/TicketListView.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/TicketListView.swift')
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 354 |
1 files changed, 316 insertions, 38 deletions
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 { |
