Commit 30bac29e44
Unsigned
Layout: unified · split
Hutch/App/AppStorageKeys.swift +2
| @@ -14,6 +14,8 @@ enum AppStorageKeys { | |||
| 14 | static let homeBuildsExpanded = "homeBuildsExpanded" | 14 | static let homeBuildsExpanded = "homeBuildsExpanded" |
| 15 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" | 15 | static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" |
| 16 | static let buildsRepoFilter = "buildsRepoFilter" | 16 | static let buildsRepoFilter = "buildsRepoFilter" |
| 17 | static let ticketFilterState = "ticketFilterState" | ||
| 18 | static let ticketSavedFilters = "ticketSavedFilters" | ||
| 17 | static let appTheme = "appTheme" | 19 | static let appTheme = "appTheme" |
| 18 | static let displayDensity = "displayDensity" | 20 | static let displayDensity = "displayDensity" |
| 19 | } | 21 | } |
Hutch/Views/Builds/BuildTaskLogView.swift +2 −2
| @@ -407,7 +407,7 @@ private struct LogScrollTarget: Equatable { | |||
| 407 | let range: LogTextRange | 407 | let range: LogTextRange |
| 408 | } | 408 | } |
| 409 | 409 | ||
| 410 | func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] { | 410 | nonisolated func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] { |
| 411 | let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) | 411 | let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) |
| 412 | guard !trimmedQuery.isEmpty else { return [] } | 412 | guard !trimmedQuery.isEmpty else { return [] } |
| 413 | 413 | ||
| @@ -433,7 +433,7 @@ func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogT | |||
| 433 | return matches | 433 | return matches |
| 434 | } | 434 | } |
| 435 | 435 | ||
| 436 | func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] { | 436 | nonisolated func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] { |
| 437 | let nsText = text as NSString | 437 | let nsText = text as NSString |
| 438 | let strongMarkers = [ | 438 | let strongMarkers = [ |
| 439 | "fatal error", | 439 | "fatal error", |
Hutch/Views/Tickets/TicketListView.swift +239 −6
| @@ -13,6 +13,8 @@ struct TicketListView: View { | |||
| 13 | @State private var showCreateTicketSheet = false | 13 | @State private var showCreateTicketSheet = false |
| 14 | @State private var createdTicket: TicketSummary? | 14 | @State private var createdTicket: TicketSummary? |
| 15 | @State private var labelEditorTicket: LabelEditorTicket? | 15 | @State private var labelEditorTicket: LabelEditorTicket? |
| 16 | @State private var showLabelFilterSheet = false | ||
| 17 | @State private var showSaveFilterSheet = false | ||
| 16 | @State private var showTrackerEditor = false | 18 | @State private var showTrackerEditor = false |
| 17 | @State private var showTrackerACLs = false | 19 | @State private var showTrackerACLs = false |
| 18 | @State private var showTrackerLabels = false | 20 | @State private var showTrackerLabels = false |
| @@ -86,6 +88,18 @@ struct TicketListView: View { | |||
| 86 | .presentationDetents([.medium]) | 88 | .presentationDetents([.medium]) |
| 87 | } | 89 | } |
| 88 | } | 90 | } |
| 91 | .sheet(isPresented: $showLabelFilterSheet) { | ||
| 92 | if let viewModel { | ||
| 93 | TicketFilterLabelsSheet(viewModel: viewModel) | ||
| 94 | .presentationDetents([.medium, .large]) | ||
| 95 | } | ||
| 96 | } | ||
| 97 | .sheet(isPresented: $showSaveFilterSheet) { | ||
| 98 | if let viewModel { | ||
| 99 | SaveTicketFilterSheet(viewModel: viewModel) | ||
| 100 | .presentationDetents([.height(220)]) | ||
| 101 | } | ||
| 102 | } | ||
| 89 | .sheet(isPresented: $showTrackerEditor) { | 103 | .sheet(isPresented: $showTrackerEditor) { |
| 90 | if let trackerManagementViewModel { | 104 | if let trackerManagementViewModel { |
| 91 | TrackerEditorSheet( | 105 | TrackerEditorSheet( |
| @@ -169,6 +183,7 @@ struct TicketListView: View { | |||
| 169 | viewModel = vm | 183 | viewModel = vm |
| 170 | trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) | 184 | trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) |
| 171 | await vm.loadTickets() | 185 | await vm.loadTickets() |
| 186 | await vm.loadTrackerLabels() | ||
| 172 | } | 187 | } |
| 173 | } | 188 | } |
| 174 | } | 189 | } |
| @@ -178,14 +193,33 @@ struct TicketListView: View { | |||
| 178 | @Bindable var vm = viewModel | 193 | @Bindable var vm = viewModel |
| 179 | 194 | ||
| 180 | List { | 195 | List { |
| 181 | // Filter picker | ||
| 182 | Section { | 196 | Section { |
| 183 | Picker("Filter", selection: $vm.filter) { | 197 | VStack(alignment: .leading, spacing: 12) { |
| 184 | ForEach(TicketFilter.allCases, id: \.self) { filter in | 198 | Picker("Filter", selection: $vm.filter) { |
| 185 | Text(filter.rawValue).tag(filter) | 199 | ForEach(TicketFilter.allCases, id: \.self) { filter in |
| 200 | Text(filter.rawValue).tag(filter) | ||
| 201 | } | ||
| 202 | } | ||
| 203 | .pickerStyle(.segmented) | ||
| 204 | |||
| 205 | TicketQuickFilterBar( | ||
| 206 | selectedLabels: viewModel.selectedLabels, | ||
| 207 | savedFilters: viewModel.savedFilters, | ||
| 208 | activeSavedFilterID: viewModel.activeSavedFilterID, | ||
| 209 | canSaveCurrentFilter: viewModel.hasCustomFilterSelection | ||
| 210 | ) { | ||
| 211 | showLabelFilterSheet = true | ||
| 212 | } onSaveFilter: { | ||
| 213 | showSaveFilterSheet = true | ||
| 214 | } onResetFilters: { | ||
| 215 | vm.resetFilters() | ||
| 216 | } onApplySavedFilter: { savedFilter in | ||
| 217 | vm.applySavedFilter(savedFilter) | ||
| 218 | } onDeleteSavedFilter: { savedFilter in | ||
| 219 | vm.deleteSavedFilter(savedFilter) | ||
| 186 | } | 220 | } |
| 187 | } | 221 | } |
| 188 | .pickerStyle(.segmented) | 222 | .padding(.vertical, 4) |
| 189 | .listRowBackground(Color.clear) | 223 | .listRowBackground(Color.clear) |
| 190 | .listRowInsets(EdgeInsets()) | 224 | .listRowInsets(EdgeInsets()) |
| 191 | } | 225 | } |
| @@ -250,7 +284,7 @@ struct TicketListView: View { | |||
| 250 | ContentUnavailableView( | 284 | ContentUnavailableView( |
| 251 | "No Tickets", | 285 | "No Tickets", |
| 252 | systemImage: "ticket", | 286 | systemImage: "ticket", |
| 253 | description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.") | 287 | description: Text(emptyStateDescription(for: viewModel)) |
| 254 | ) | 288 | ) |
| 255 | } | 289 | } |
| 256 | } | 290 | } |
| @@ -263,6 +297,13 @@ struct TicketListView: View { | |||
| 263 | } | 297 | } |
| 264 | } | 298 | } |
| 265 | 299 | ||
| 300 | private func emptyStateDescription(for viewModel: TicketListViewModel) -> String { | ||
| 301 | if !viewModel.selectedLabelIDs.isEmpty { | ||
| 302 | return "No \(viewModel.filter.rawValue.lowercased()) tickets found for the selected labels." | ||
| 303 | } | ||
| 304 | return "No \(viewModel.filter.rawValue.lowercased()) tickets found." | ||
| 305 | } | ||
| 306 | |||
| 266 | private var trackerActionsMenu: some View { | 307 | private var trackerActionsMenu: some View { |
| 267 | Menu { | 308 | Menu { |
| 268 | Button { | 309 | Button { |
| @@ -497,6 +538,198 @@ private struct TicketLabelsSheet: View { | |||
| 497 | } | 538 | } |
| 498 | } | 539 | } |
| 499 | 540 | ||
| 541 | private struct TicketQuickFilterBar: View { | ||
| 542 | let selectedLabels: [TicketLabel] | ||
| 543 | let savedFilters: [SavedTicketFilter] | ||
| 544 | let activeSavedFilterID: SavedTicketFilter.ID? | ||
| 545 | let canSaveCurrentFilter: Bool | ||
| 546 | let onShowLabels: () -> Void | ||
| 547 | let onSaveFilter: () -> Void | ||
| 548 | let onResetFilters: () -> Void | ||
| 549 | let onApplySavedFilter: (SavedTicketFilter) -> Void | ||
| 550 | let onDeleteSavedFilter: (SavedTicketFilter) -> Void | ||
| 551 | |||
| 552 | var body: some View { | ||
| 553 | VStack(alignment: .leading, spacing: 10) { | ||
| 554 | HStack(spacing: 8) { | ||
| 555 | Button(action: onShowLabels) { | ||
| 556 | Label(labelButtonTitle, systemImage: "tag") | ||
| 557 | .font(.caption.weight(.medium)) | ||
| 558 | } | ||
| 559 | .buttonStyle(.bordered) | ||
| 560 | |||
| 561 | Button(action: onSaveFilter) { | ||
| 562 | Label("Save Filter", systemImage: "square.and.arrow.down") | ||
| 563 | .font(.caption.weight(.medium)) | ||
| 564 | } | ||
| 565 | .buttonStyle(.bordered) | ||
| 566 | .disabled(!canSaveCurrentFilter) | ||
| 567 | |||
| 568 | if canSaveCurrentFilter { | ||
| 569 | Button("Reset", action: onResetFilters) | ||
| 570 | .font(.caption.weight(.medium)) | ||
| 571 | .buttonStyle(.bordered) | ||
| 572 | } | ||
| 573 | } | ||
| 574 | |||
| 575 | if !selectedLabels.isEmpty { | ||
| 576 | ScrollView(.horizontal, showsIndicators: false) { | ||
| 577 | HStack(spacing: 8) { | ||
| 578 | ForEach(selectedLabels) { label in | ||
| 579 | LabelPill(label: label) | ||
| 580 | } | ||
| 581 | } | ||
| 582 | } | ||
| 583 | } | ||
| 584 | |||
| 585 | if !savedFilters.isEmpty { | ||
| 586 | ScrollView(.horizontal, showsIndicators: false) { | ||
| 587 | HStack(spacing: 8) { | ||
| 588 | ForEach(savedFilters) { savedFilter in | ||
| 589 | Button { | ||
| 590 | onApplySavedFilter(savedFilter) | ||
| 591 | } label: { | ||
| 592 | HStack(spacing: 6) { | ||
| 593 | Image(systemName: activeSavedFilterID == savedFilter.id ? "checkmark.circle.fill" : "line.3.horizontal.decrease.circle") | ||
| 594 | .imageScale(.small) | ||
| 595 | Text(savedFilter.name) | ||
| 596 | .lineLimit(1) | ||
| 597 | } | ||
| 598 | .font(.caption.weight(.medium)) | ||
| 599 | .padding(.horizontal, 10) | ||
| 600 | .padding(.vertical, 7) | ||
| 601 | .foregroundStyle(activeSavedFilterID == savedFilter.id ? Color.accentColor : Color.primary) | ||
| 602 | .background( | ||
| 603 | activeSavedFilterID == savedFilter.id ? | ||
| 604 | Color.accentColor.opacity(0.14) : | ||
| 605 | Color(.secondarySystemFill), | ||
| 606 | in: Capsule() | ||
| 607 | ) | ||
| 608 | } | ||
| 609 | .contextMenu { | ||
| 610 | Button(role: .destructive) { | ||
| 611 | onDeleteSavedFilter(savedFilter) | ||
| 612 | } label: { | ||
| 613 | Label("Delete Filter", systemImage: "trash") | ||
| 614 | } | ||
| 615 | } | ||
| 616 | } | ||
| 617 | } | ||
| 618 | } | ||
| 619 | } | ||
| 620 | } | ||
| 621 | } | ||
| 622 | |||
| 623 | private var labelButtonTitle: String { | ||
| 624 | selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))" | ||
| 625 | } | ||
| 626 | } | ||
| 627 | |||
| 628 | private struct TicketFilterLabelsSheet: View { | ||
| 629 | let viewModel: TicketListViewModel | ||
| 630 | |||
| 631 | @Environment(\.dismiss) private var dismiss | ||
| 632 | |||
| 633 | var body: some View { | ||
| 634 | NavigationStack { | ||
| 635 | Group { | ||
| 636 | if viewModel.availableLabels.isEmpty { | ||
| 637 | ContentUnavailableView( | ||
| 638 | "No Labels", | ||
| 639 | systemImage: "tag", | ||
| 640 | description: Text("This tracker has no labels available for filtering yet.") | ||
| 641 | ) | ||
| 642 | } else { | ||
| 643 | List { | ||
| 644 | if !viewModel.selectedLabels.isEmpty { | ||
| 645 | Section("Selected") { | ||
| 646 | FlowLayout(spacing: 6) { | ||
| 647 | ForEach(viewModel.selectedLabels) { label in | ||
| 648 | LabelPill(label: label) | ||
| 649 | } | ||
| 650 | } | ||
| 651 | .padding(.vertical, 4) | ||
| 652 | } | ||
| 653 | } | ||
| 654 | |||
| 655 | Section("Labels") { | ||
| 656 | ForEach(viewModel.availableLabels) { label in | ||
| 657 | Button { | ||
| 658 | viewModel.toggleLabelSelection(label) | ||
| 659 | } label: { | ||
| 660 | HStack { | ||
| 661 | LabelPill(label: label) | ||
| 662 | Spacer() | ||
| 663 | if viewModel.selectedLabelIDs.contains(label.id) { | ||
| 664 | Image(systemName: "checkmark") | ||
| 665 | .foregroundStyle(.blue) | ||
| 666 | } | ||
| 667 | } | ||
| 668 | } | ||
| 669 | .buttonStyle(.plain) | ||
| 670 | } | ||
| 671 | } | ||
| 672 | } | ||
| 673 | } | ||
| 674 | } | ||
| 675 | .navigationTitle("Filter Labels") | ||
| 676 | .navigationBarTitleDisplayMode(.inline) | ||
| 677 | .toolbar { | ||
| 678 | ToolbarItem(placement: .cancellationAction) { | ||
| 679 | Button("Done") { dismiss() } | ||
| 680 | } | ||
| 681 | if !viewModel.selectedLabelIDs.isEmpty { | ||
| 682 | ToolbarItem(placement: .confirmationAction) { | ||
| 683 | Button("Clear") { viewModel.clearLabelSelection() } | ||
| 684 | } | ||
| 685 | } | ||
| 686 | } | ||
| 687 | .task { | ||
| 688 | if viewModel.availableLabels.isEmpty { | ||
| 689 | await viewModel.loadTrackerLabels() | ||
| 690 | } | ||
| 691 | } | ||
| 692 | } | ||
| 693 | } | ||
| 694 | } | ||
| 695 | |||
| 696 | private struct SaveTicketFilterSheet: View { | ||
| 697 | let viewModel: TicketListViewModel | ||
| 698 | |||
| 699 | @Environment(\.dismiss) private var dismiss | ||
| 700 | @State private var name: String | ||
| 701 | |||
| 702 | init(viewModel: TicketListViewModel) { | ||
| 703 | self.viewModel = viewModel | ||
| 704 | self._name = State(initialValue: viewModel.suggestedSavedFilterName) | ||
| 705 | } | ||
| 706 | |||
| 707 | var body: some View { | ||
| 708 | NavigationStack { | ||
| 709 | Form { | ||
| 710 | Section("Name") { | ||
| 711 | TextField("Filter name", text: $name) | ||
| 712 | .textInputAutocapitalization(.words) | ||
| 713 | } | ||
| 714 | } | ||
| 715 | .navigationTitle("Save Filter") | ||
| 716 | .navigationBarTitleDisplayMode(.inline) | ||
| 717 | .toolbar { | ||
| 718 | ToolbarItem(placement: .cancellationAction) { | ||
| 719 | Button("Cancel") { dismiss() } | ||
| 720 | } | ||
| 721 | ToolbarItem(placement: .confirmationAction) { | ||
| 722 | Button("Save") { | ||
| 723 | viewModel.saveCurrentFilter(named: name) | ||
| 724 | dismiss() | ||
| 725 | } | ||
| 726 | .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | ||
| 727 | } | ||
| 728 | } | ||
| 729 | } | ||
| 730 | } | ||
| 731 | } | ||
| 732 | |||
| 500 | private struct TicketListLabelToggleRow: View { | 733 | private struct TicketListLabelToggleRow: View { |
| 501 | let label: TicketLabel | 734 | let label: TicketLabel |
| 502 | let isApplied: Bool | 735 | let isApplied: Bool |
Hutch/Views/Tickets/TicketListViewModel.swift +149 −29
| @@ -55,7 +55,7 @@ private struct MutationEventRef: Decodable, Sendable { | |||
| 55 | 55 | ||
| 56 | // MARK: - Filter | 56 | // MARK: - Filter |
| 57 | 57 | ||
| 58 | enum TicketFilter: String, CaseIterable, Sendable { | 58 | enum TicketFilter: String, CaseIterable, Codable, Sendable { |
| 59 | case open = "Open" | 59 | case open = "Open" |
| 60 | case resolved = "Resolved" | 60 | case resolved = "Resolved" |
| 61 | case all = "All" | 61 | case all = "All" |
| @@ -77,32 +77,46 @@ final class TicketListViewModel { | |||
| 77 | private(set) var isCreatingTicket = false | 77 | private(set) var isCreatingTicket = false |
| 78 | private(set) var isPerformingAction = false | 78 | private(set) var isPerformingAction = false |
| 79 | private(set) var trackerLabels: [TicketLabel] = [] | 79 | private(set) var trackerLabels: [TicketLabel] = [] |
| 80 | private(set) var savedFilters: [SavedTicketFilter] | ||
| 80 | var error: String? | 81 | var error: String? |
| 81 | var filter: TicketFilter = .open { | 82 | var filter: TicketFilter = .open { |
| 82 | didSet { | 83 | didSet { |
| 83 | UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey) | 84 | persistFilterState() |
| 85 | } | ||
| 86 | } | ||
| 87 | var selectedLabelIDs: Set<Int> = [] { | ||
| 88 | didSet { | ||
| 89 | persistFilterState() | ||
| 84 | } | 90 | } |
| 85 | } | 91 | } |
| 86 | var searchText = "" | 92 | var searchText = "" |
| 93 | private(set) var activeSavedFilterID: SavedTicketFilter.ID? | ||
| 87 | 94 | ||
| 88 | private var cursor: String? | 95 | private var cursor: String? |
| 89 | private var hasMore = true | 96 | private var hasMore = true |
| 90 | private let client: SRHTClient | 97 | private let client: SRHTClient |
| 91 | 98 | private let defaults: UserDefaults | |
| 92 | private var filterDefaultsKey: String { | 99 | |
| 93 | "ticketFilter_\(trackerRid)" | 100 | init( |
| 94 | } | 101 | ownerUsername: String, |
| 95 | 102 | trackerName: String, | |
| 96 | init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) { | 103 | trackerId: Int, |
| 104 | trackerRid: String, | ||
| 105 | client: SRHTClient, | ||
| 106 | defaults: UserDefaults = .standard | ||
| 107 | ) { | ||
| 97 | self.ownerUsername = ownerUsername | 108 | self.ownerUsername = ownerUsername |
| 98 | self.trackerName = trackerName | 109 | self.trackerName = trackerName |
| 99 | self.trackerId = trackerId | 110 | self.trackerId = trackerId |
| 100 | self.trackerRid = trackerRid | 111 | self.trackerRid = trackerRid |
| 101 | self.client = client | 112 | self.client = client |
| 102 | if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey), | 113 | self.defaults = defaults |
| 103 | let restored = TicketFilter(rawValue: raw) { | 114 | |
| 104 | self.filter = restored | 115 | let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults) |
| 105 | } | 116 | self.filter = restoredState.status |
| 117 | self.selectedLabelIDs = Set(restoredState.labelIDs) | ||
| 118 | self.savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults) | ||
| 119 | self.activeSavedFilterID = self.savedFilters.first(where: { $0.state == restoredState })?.id | ||
| 106 | } | 120 | } |
| 107 | 121 | ||
| 108 | // MARK: - Query | 122 | // MARK: - Query |
| @@ -186,25 +200,43 @@ final class TicketListViewModel { | |||
| 186 | 200 | ||
| 187 | // MARK: - Computed | 201 | // MARK: - Computed |
| 188 | 202 | ||
| 189 | /// Tickets filtered by the selected status filter. | 203 | var currentFilterState: TicketListFilterState { |
| 190 | var filteredTickets: [TicketSummary] { | 204 | TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs)) |
| 191 | let statusFiltered: [TicketSummary] | 205 | } |
| 192 | switch filter { | 206 | |
| 193 | case .open: | 207 | var availableLabels: [TicketLabel] { |
| 194 | statusFiltered = tickets.filter { $0.status.isOpen } | 208 | let combinedLabels = trackerLabels + tickets.flatMap(\.labels) |
| 195 | case .resolved: | 209 | let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in |
| 196 | statusFiltered = tickets.filter { !$0.status.isOpen } | 210 | partialResult[label.id] = label |
| 197 | case .all: | ||
| 198 | statusFiltered = tickets | ||
| 199 | } | 211 | } |
| 200 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | 212 | return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } |
| 201 | guard !q.isEmpty else { return statusFiltered } | 213 | } |
| 202 | return statusFiltered.filter { | 214 | |
| 203 | String($0.id).contains(q) || | 215 | var selectedLabels: [TicketLabel] { |
| 204 | $0.title.lowercased().contains(q) || | 216 | availableLabels.filter { selectedLabelIDs.contains($0.id) } |
| 205 | $0.submitter.canonicalName.lowercased().contains(q) || | 217 | } |
| 206 | $0.labels.contains { $0.name.lowercased().contains(q) } | 218 | |
| 219 | var suggestedSavedFilterName: String { | ||
| 220 | let labelNames = selectedLabels.map(\.name).sorted() | ||
| 221 | var components: [String] = [] | ||
| 222 | |||
| 223 | if filter != .open || !labelNames.isEmpty { | ||
| 224 | components.append(filter.rawValue) | ||
| 225 | } | ||
| 226 | if !labelNames.isEmpty { | ||
| 227 | components.append(labelNames.joined(separator: ", ")) | ||
| 207 | } | 228 | } |
| 229 | |||
| 230 | return components.isEmpty ? "Open Tickets" : components.joined(separator: " • ") | ||
| 231 | } | ||
| 232 | |||
| 233 | var hasCustomFilterSelection: Bool { | ||
| 234 | !currentFilterState.isDefault | ||
| 235 | } | ||
| 236 | |||
| 237 | /// Tickets filtered by the selected status and label filters. | ||
| 238 | var filteredTickets: [TicketSummary] { | ||
| 239 | Self.filterTickets(tickets, state: currentFilterState, query: searchText) | ||
| 208 | } | 240 | } |
| 209 | 241 | ||
| 210 | // MARK: - Public API | 242 | // MARK: - Public API |
| @@ -401,6 +433,54 @@ final class TicketListViewModel { | |||
| 401 | } | 433 | } |
| 402 | } | 434 | } |
| 403 | 435 | ||
| 436 | func toggleLabelSelection(_ label: TicketLabel) { | ||
| 437 | if selectedLabelIDs.contains(label.id) { | ||
| 438 | selectedLabelIDs.remove(label.id) | ||
| 439 | } else { | ||
| 440 | selectedLabelIDs.insert(label.id) | ||
| 441 | } | ||
| 442 | } | ||
| 443 | |||
| 444 | func clearLabelSelection() { | ||
| 445 | selectedLabelIDs = [] | ||
| 446 | } | ||
| 447 | |||
| 448 | func resetFilters() { | ||
| 449 | filter = .open | ||
| 450 | selectedLabelIDs = [] | ||
| 451 | } | ||
| 452 | |||
| 453 | func applySavedFilter(_ savedFilter: SavedTicketFilter) { | ||
| 454 | filter = savedFilter.state.status | ||
| 455 | selectedLabelIDs = Set(savedFilter.state.labelIDs) | ||
| 456 | activeSavedFilterID = savedFilter.id | ||
| 457 | } | ||
| 458 | |||
| 459 | func saveCurrentFilter(named name: String) { | ||
| 460 | guard let savedFilter = TicketSavedFilterStore.saveFilter( | ||
| 461 | named: name, | ||
| 462 | state: currentFilterState, | ||
| 463 | for: trackerRid, | ||
| 464 | defaults: defaults | ||
| 465 | ) else { | ||
| 466 | return | ||
| 467 | } | ||
| 468 | |||
| 469 | savedFilters.removeAll { | ||
| 470 | $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame | ||
| 471 | } | ||
| 472 | savedFilters.insert(savedFilter, at: 0) | ||
| 473 | activeSavedFilterID = savedFilter.id | ||
| 474 | } | ||
| 475 | |||
| 476 | func deleteSavedFilter(_ savedFilter: SavedTicketFilter) { | ||
| 477 | TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults) | ||
| 478 | savedFilters.removeAll { $0.id == savedFilter.id } | ||
| 479 | if activeSavedFilterID == savedFilter.id { | ||
| 480 | activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id | ||
| 481 | } | ||
| 482 | } | ||
| 483 | |||
| 404 | func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async { | 484 | func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async { |
| 405 | guard !isPerformingAction else { return } | 485 | guard !isPerformingAction else { return } |
| 406 | isPerformingAction = true | 486 | isPerformingAction = true |
| @@ -569,4 +649,44 @@ final class TicketListViewModel { | |||
| 569 | assignees: ticket.assignees | 649 | assignees: ticket.assignees |
| 570 | ) | 650 | ) |
| 571 | } | 651 | } |
| 652 | |||
| 653 | private func persistFilterState() { | ||
| 654 | TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults) | ||
| 655 | activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id | ||
| 656 | } | ||
| 657 | |||
| 658 | static func filterTickets( | ||
| 659 | _ tickets: [TicketSummary], | ||
| 660 | state: TicketListFilterState, | ||
| 661 | query: String | ||
| 662 | ) -> [TicketSummary] { | ||
| 663 | let statusFiltered: [TicketSummary] | ||
| 664 | switch state.status { | ||
| 665 | case .open: | ||
| 666 | statusFiltered = tickets.filter { $0.status.isOpen } | ||
| 667 | case .resolved: | ||
| 668 | statusFiltered = tickets.filter { !$0.status.isOpen } | ||
| 669 | case .all: | ||
| 670 | statusFiltered = tickets | ||
| 671 | } | ||
| 672 | |||
| 673 | let labelFiltered: [TicketSummary] | ||
| 674 | if state.labelIDs.isEmpty { | ||
| 675 | labelFiltered = statusFiltered | ||
| 676 | } else { | ||
| 677 | let selectedLabelIDs = Set(state.labelIDs) | ||
| 678 | labelFiltered = statusFiltered.filter { ticket in | ||
| 679 | !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id)) | ||
| 680 | } | ||
| 681 | } | ||
| 682 | |||
| 683 | let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 684 | guard !q.isEmpty else { return labelFiltered } | ||
| 685 | return labelFiltered.filter { | ||
| 686 | String($0.id).contains(q) || | ||
| 687 | $0.title.lowercased().contains(q) || | ||
| 688 | $0.submitter.canonicalName.lowercased().contains(q) || | ||
| 689 | $0.labels.contains { $0.name.lowercased().contains(q) } | ||
| 690 | } | ||
| 691 | } | ||
| 572 | } | 692 | } |
Hutch/Views/Tickets/TicketSavedFilterStore.swift added +142
| @@ -0,0 +1,142 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct TicketListFilterState: Codable, Hashable, Sendable { | ||
| 4 | var status: TicketFilter | ||
| 5 | var labelIDs: [Int] | ||
| 6 | |||
| 7 | init(status: TicketFilter = .open, labelIDs: [Int] = []) { | ||
| 8 | self.status = status | ||
| 9 | self.labelIDs = Array(Set(labelIDs)).sorted() | ||
| 10 | } | ||
| 11 | |||
| 12 | var isDefault: Bool { | ||
| 13 | status == .open && labelIDs.isEmpty | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | struct SavedTicketFilter: Codable, Hashable, Identifiable, Sendable { | ||
| 18 | let id: UUID | ||
| 19 | let name: String | ||
| 20 | let state: TicketListFilterState | ||
| 21 | let createdAt: Date | ||
| 22 | } | ||
| 23 | |||
| 24 | enum TicketSavedFilterStore { | ||
| 25 | static func loadCurrentState( | ||
| 26 | for trackerID: String, | ||
| 27 | defaults: UserDefaults = .standard | ||
| 28 | ) -> TicketListFilterState { | ||
| 29 | let allStates = loadStates(defaults: defaults) | ||
| 30 | if let savedState = allStates[trackerID] { | ||
| 31 | return savedState | ||
| 32 | } | ||
| 33 | |||
| 34 | if let legacyRawValue = defaults.string(forKey: legacyStatusKey(for: trackerID)), | ||
| 35 | let status = TicketFilter(rawValue: legacyRawValue) { | ||
| 36 | return TicketListFilterState(status: status) | ||
| 37 | } | ||
| 38 | |||
| 39 | return TicketListFilterState() | ||
| 40 | } | ||
| 41 | |||
| 42 | static func saveCurrentState( | ||
| 43 | _ state: TicketListFilterState, | ||
| 44 | for trackerID: String, | ||
| 45 | defaults: UserDefaults = .standard | ||
| 46 | ) { | ||
| 47 | var allStates = loadStates(defaults: defaults) | ||
| 48 | allStates[trackerID] = state | ||
| 49 | save(allStates, key: AppStorageKeys.ticketFilterState, defaults: defaults) | ||
| 50 | defaults.removeObject(forKey: legacyStatusKey(for: trackerID)) | ||
| 51 | } | ||
| 52 | |||
| 53 | static func loadSavedFilters( | ||
| 54 | for trackerID: String, | ||
| 55 | defaults: UserDefaults = .standard | ||
| 56 | ) -> [SavedTicketFilter] { | ||
| 57 | let allFilters: [String: [SavedTicketFilter]] = loadDictionary( | ||
| 58 | key: AppStorageKeys.ticketSavedFilters, | ||
| 59 | defaults: defaults | ||
| 60 | ) | ||
| 61 | return allFilters[trackerID] ?? [] | ||
| 62 | } | ||
| 63 | |||
| 64 | static func saveFilter( | ||
| 65 | named name: String, | ||
| 66 | state: TicketListFilterState, | ||
| 67 | for trackerID: String, | ||
| 68 | defaults: UserDefaults = .standard, | ||
| 69 | now: Date = .now | ||
| 70 | ) -> SavedTicketFilter? { | ||
| 71 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 72 | guard !trimmedName.isEmpty else { return nil } | ||
| 73 | |||
| 74 | var allFilters: [String: [SavedTicketFilter]] = loadDictionary( | ||
| 75 | key: AppStorageKeys.ticketSavedFilters, | ||
| 76 | defaults: defaults | ||
| 77 | ) | ||
| 78 | var trackerFilters = allFilters[trackerID] ?? [] | ||
| 79 | trackerFilters.removeAll { | ||
| 80 | $0.name.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame | ||
| 81 | } | ||
| 82 | |||
| 83 | let savedFilter = SavedTicketFilter( | ||
| 84 | id: UUID(), | ||
| 85 | name: trimmedName, | ||
| 86 | state: state, | ||
| 87 | createdAt: now | ||
| 88 | ) | ||
| 89 | trackerFilters.insert(savedFilter, at: 0) | ||
| 90 | allFilters[trackerID] = trackerFilters | ||
| 91 | save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults) | ||
| 92 | return savedFilter | ||
| 93 | } | ||
| 94 | |||
| 95 | static func deleteFilter( | ||
| 96 | id: SavedTicketFilter.ID, | ||
| 97 | for trackerID: String, | ||
| 98 | defaults: UserDefaults = .standard | ||
| 99 | ) { | ||
| 100 | var allFilters: [String: [SavedTicketFilter]] = loadDictionary( | ||
| 101 | key: AppStorageKeys.ticketSavedFilters, | ||
| 102 | defaults: defaults | ||
| 103 | ) | ||
| 104 | var trackerFilters = allFilters[trackerID] ?? [] | ||
| 105 | trackerFilters.removeAll { $0.id == id } | ||
| 106 | allFilters[trackerID] = trackerFilters | ||
| 107 | save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults) | ||
| 108 | } | ||
| 109 | |||
| 110 | private static func loadStates(defaults: UserDefaults) -> [String: TicketListFilterState] { | ||
| 111 | loadDictionary(key: AppStorageKeys.ticketFilterState, defaults: defaults) | ||
| 112 | } | ||
| 113 | |||
| 114 | private static func loadDictionary<T: Decodable>( | ||
| 115 | key: String, | ||
| 116 | defaults: UserDefaults | ||
| 117 | ) -> [String: T] { | ||
| 118 | guard let data = defaults.data(forKey: key) else { | ||
| 119 | return [:] | ||
| 120 | } | ||
| 121 | |||
| 122 | do { | ||
| 123 | return try JSONDecoder().decode([String: T].self, from: data) | ||
| 124 | } catch { | ||
| 125 | defaults.removeObject(forKey: key) | ||
| 126 | return [:] | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | private static func save<T: Encodable>( | ||
| 131 | _ value: [String: T], | ||
| 132 | key: String, | ||
| 133 | defaults: UserDefaults | ||
| 134 | ) { | ||
| 135 | guard let data = try? JSONEncoder().encode(value) else { return } | ||
| 136 | defaults.set(data, forKey: key) | ||
| 137 | } | ||
| 138 | |||
| 139 | private static func legacyStatusKey(for trackerID: String) -> String { | ||
| 140 | "ticketFilter_\(trackerID)" | ||
| 141 | } | ||
| 142 | } | ||
HutchTests/TicketListViewModelTests.swift +48 −24
| @@ -11,7 +11,11 @@ struct TicketListViewModelTests { | |||
| 11 | makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: []) | 11 | makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: []) |
| 12 | ] | 12 | ] |
| 13 | 13 | ||
| 14 | let filtered = filterTickets(tickets, filter: .open, query: "") | 14 | let filtered = filterTickets( |
| 15 | tickets, | ||
| 16 | state: TicketListFilterState(status: .open), | ||
| 17 | query: "" | ||
| 18 | ) | ||
| 15 | 19 | ||
| 16 | #expect(filtered.map(\.id) == [1]) | 20 | #expect(filtered.map(\.id) == [1]) |
| 17 | } | 21 | } |
| @@ -23,8 +27,16 @@ struct TicketListViewModelTests { | |||
| 23 | makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: []) | 27 | makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: []) |
| 24 | ] | 28 | ] |
| 25 | 29 | ||
| 26 | let titleMatches = filterTickets(tickets, filter: .all, query: "settings") | 30 | let titleMatches = filterTickets( |
| 27 | let idMatches = filterTickets(tickets, filter: .all, query: "42") | 31 | tickets, |
| 32 | state: TicketListFilterState(status: .all), | ||
| 33 | query: "settings" | ||
| 34 | ) | ||
| 35 | let idMatches = filterTickets( | ||
| 36 | tickets, | ||
| 37 | state: TicketListFilterState(status: .all), | ||
| 38 | query: "42" | ||
| 39 | ) | ||
| 28 | 40 | ||
| 29 | #expect(titleMatches.map(\.id) == [99]) | 41 | #expect(titleMatches.map(\.id) == [99]) |
| 30 | #expect(idMatches.map(\.id) == [42]) | 42 | #expect(idMatches.map(\.id) == [42]) |
| @@ -37,13 +49,38 @@ struct TicketListViewModelTests { | |||
| 37 | makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")]) | 49 | makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")]) |
| 38 | ] | 50 | ] |
| 39 | 51 | ||
| 40 | let submitterMatches = filterTickets(tickets, filter: .all, query: "~triage") | 52 | let submitterMatches = filterTickets( |
| 41 | let labelMatches = filterTickets(tickets, filter: .all, query: "bug") | 53 | tickets, |
| 54 | state: TicketListFilterState(status: .all), | ||
| 55 | query: "~triage" | ||
| 56 | ) | ||
| 57 | let labelMatches = filterTickets( | ||
| 58 | tickets, | ||
| 59 | state: TicketListFilterState(status: .all), | ||
| 60 | query: "bug" | ||
| 61 | ) | ||
| 42 | 62 | ||
| 43 | #expect(submitterMatches.map(\.id) == [2]) | 63 | #expect(submitterMatches.map(\.id) == [2]) |
| 44 | #expect(labelMatches.map(\.id) == [1]) | 64 | #expect(labelMatches.map(\.id) == [1]) |
| 45 | } | 65 | } |
| 46 | 66 | ||
| 67 | @Test | ||
| 68 | func filteredTicketsMatchesAnySelectedLabel() { | ||
| 69 | let tickets = [ | ||
| 70 | makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: [makeLabel(id: 1, name: "bug")]), | ||
| 71 | makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")]), | ||
| 72 | makeTicket(id: 3, title: "Unlabeled", status: .reported, submitter: "~owner", labels: []) | ||
| 73 | ] | ||
| 74 | |||
| 75 | let filtered = filterTickets( | ||
| 76 | tickets, | ||
| 77 | state: TicketListFilterState(status: .all, labelIDs: [2, 3]), | ||
| 78 | query: "" | ||
| 79 | ) | ||
| 80 | |||
| 81 | #expect(filtered.map(\.id) == [2]) | ||
| 82 | } | ||
| 83 | |||
| 47 | @Test | 84 | @Test |
| 48 | @MainActor | 85 | @MainActor |
| 49 | func resolveTicketInputHasCorrectStatusAndDefaultResolution() { | 86 | func resolveTicketInputHasCorrectStatusAndDefaultResolution() { |
| @@ -65,25 +102,12 @@ struct TicketListViewModelTests { | |||
| 65 | #expect(input["resolution"] == nil) | 102 | #expect(input["resolution"] == nil) |
| 66 | } | 103 | } |
| 67 | 104 | ||
| 68 | private func filterTickets(_ tickets: [TicketSummary], filter: TicketFilter, query: String) -> [TicketSummary] { | 105 | private func filterTickets( |
| 69 | let statusFiltered: [TicketSummary] | 106 | _ tickets: [TicketSummary], |
| 70 | switch filter { | 107 | state: TicketListFilterState, |
| 71 | case .open: | 108 | query: String |
| 72 | statusFiltered = tickets.filter { $0.status.isOpen } | 109 | ) -> [TicketSummary] { |
| 73 | case .resolved: | 110 | TicketListViewModel.filterTickets(tickets, state: state, query: query) |
| 74 | statusFiltered = tickets.filter { !$0.status.isOpen } | ||
| 75 | case .all: | ||
| 76 | statusFiltered = tickets | ||
| 77 | } | ||
| 78 | |||
| 79 | let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 80 | guard !q.isEmpty else { return statusFiltered } | ||
| 81 | return statusFiltered.filter { | ||
| 82 | String($0.id).contains(q) || | ||
| 83 | $0.title.lowercased().contains(q) || | ||
| 84 | $0.submitter.canonicalName.lowercased().contains(q) || | ||
| 85 | $0.labels.contains { $0.name.lowercased().contains(q) } | ||
| 86 | } | ||
| 87 | } | 111 | } |
| 88 | 112 | ||
| 89 | private func makeTicket( | 113 | private func makeTicket( |
HutchTests/TicketSavedFilterStoreTests.swift added +88
| @@ -0,0 +1,88 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct TicketSavedFilterStoreTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | func storesCurrentFilterStatePerTracker() { | ||
| 9 | let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)" | ||
| 10 | let defaults = UserDefaults(suiteName: suiteName)! | ||
| 11 | defer { defaults.removePersistentDomain(forName: suiteName) } | ||
| 12 | |||
| 13 | TicketSavedFilterStore.saveCurrentState( | ||
| 14 | TicketListFilterState(status: .all, labelIDs: [4, 1]), | ||
| 15 | for: "tracker-a", | ||
| 16 | defaults: defaults | ||
| 17 | ) | ||
| 18 | TicketSavedFilterStore.saveCurrentState( | ||
| 19 | TicketListFilterState(status: .resolved, labelIDs: [9]), | ||
| 20 | for: "tracker-b", | ||
| 21 | defaults: defaults | ||
| 22 | ) | ||
| 23 | |||
| 24 | #expect( | ||
| 25 | TicketSavedFilterStore.loadCurrentState(for: "tracker-a", defaults: defaults) == | ||
| 26 | TicketListFilterState(status: .all, labelIDs: [1, 4]) | ||
| 27 | ) | ||
| 28 | #expect( | ||
| 29 | TicketSavedFilterStore.loadCurrentState(for: "tracker-b", defaults: defaults) == | ||
| 30 | TicketListFilterState(status: .resolved, labelIDs: [9]) | ||
| 31 | ) | ||
| 32 | } | ||
| 33 | |||
| 34 | @Test | ||
| 35 | func savesNamedFiltersPerTrackerAndReplacesDuplicateNames() { | ||
| 36 | let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)" | ||
| 37 | let defaults = UserDefaults(suiteName: suiteName)! | ||
| 38 | defer { defaults.removePersistentDomain(forName: suiteName) } | ||
| 39 | |||
| 40 | _ = TicketSavedFilterStore.saveFilter( | ||
| 41 | named: "Bugs", | ||
| 42 | state: TicketListFilterState(status: .open, labelIDs: [1]), | ||
| 43 | for: "tracker-a", | ||
| 44 | defaults: defaults, | ||
| 45 | now: Date(timeIntervalSince1970: 100) | ||
| 46 | ) | ||
| 47 | _ = TicketSavedFilterStore.saveFilter( | ||
| 48 | named: "bugs", | ||
| 49 | state: TicketListFilterState(status: .resolved, labelIDs: [2]), | ||
| 50 | for: "tracker-a", | ||
| 51 | defaults: defaults, | ||
| 52 | now: Date(timeIntervalSince1970: 200) | ||
| 53 | ) | ||
| 54 | _ = TicketSavedFilterStore.saveFilter( | ||
| 55 | named: "Needs Info", | ||
| 56 | state: TicketListFilterState(status: .all, labelIDs: [3]), | ||
| 57 | for: "tracker-b", | ||
| 58 | defaults: defaults, | ||
| 59 | now: Date(timeIntervalSince1970: 300) | ||
| 60 | ) | ||
| 61 | |||
| 62 | let trackerAFilters = TicketSavedFilterStore.loadSavedFilters(for: "tracker-a", defaults: defaults) | ||
| 63 | let trackerBFilters = TicketSavedFilterStore.loadSavedFilters(for: "tracker-b", defaults: defaults) | ||
| 64 | |||
| 65 | #expect(trackerAFilters.count == 1) | ||
| 66 | #expect(trackerAFilters.first?.name == "bugs") | ||
| 67 | #expect(trackerAFilters.first?.state == TicketListFilterState(status: .resolved, labelIDs: [2])) | ||
| 68 | #expect(trackerBFilters.map(\.name) == ["Needs Info"]) | ||
| 69 | } | ||
| 70 | |||
| 71 | @Test | ||
| 72 | func deletesSavedFilter() { | ||
| 73 | let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)" | ||
| 74 | let defaults = UserDefaults(suiteName: suiteName)! | ||
| 75 | defer { defaults.removePersistentDomain(forName: suiteName) } | ||
| 76 | |||
| 77 | let savedFilter = TicketSavedFilterStore.saveFilter( | ||
| 78 | named: "Bugs", | ||
| 79 | state: TicketListFilterState(status: .open, labelIDs: [1]), | ||
| 80 | for: "tracker-a", | ||
| 81 | defaults: defaults | ||
| 82 | ) | ||
| 83 | |||
| 84 | TicketSavedFilterStore.deleteFilter(id: savedFilter!.id, for: "tracker-a", defaults: defaults) | ||
| 85 | |||
| 86 | #expect(TicketSavedFilterStore.loadSavedFilters(for: "tracker-a", defaults: defaults).isEmpty) | ||
| 87 | } | ||
| 88 | } | ||