diff options
| -rw-r--r-- | Hutch/App/AppStorageKeys.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildTaskLogView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 245 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 178 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketSavedFilterStore.swift | 142 | ||||
| -rw-r--r-- | HutchTests/TicketListViewModelTests.swift | 72 | ||||
| -rw-r--r-- | HutchTests/TicketSavedFilterStoreTests.swift | 88 |
7 files changed, 670 insertions, 61 deletions
diff --git a/Hutch/App/AppStorageKeys.swift b/Hutch/App/AppStorageKeys.swift index 9a97657..5bb8307 100644 --- a/Hutch/App/AppStorageKeys.swift +++ b/Hutch/App/AppStorageKeys.swift @@ -14,6 +14,8 @@ enum AppStorageKeys { static let homeBuildsExpanded = "homeBuildsExpanded" static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval" static let buildsRepoFilter = "buildsRepoFilter" + static let ticketFilterState = "ticketFilterState" + static let ticketSavedFilters = "ticketSavedFilters" static let appTheme = "appTheme" static let displayDensity = "displayDensity" } diff --git a/Hutch/Views/Builds/BuildTaskLogView.swift b/Hutch/Views/Builds/BuildTaskLogView.swift index 69353bf..fda69f6 100644 --- a/Hutch/Views/Builds/BuildTaskLogView.swift +++ b/Hutch/Views/Builds/BuildTaskLogView.swift @@ -407,7 +407,7 @@ private struct LogScrollTarget: Equatable { let range: LogTextRange } -func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] { +nonisolated func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] { let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedQuery.isEmpty else { return [] } @@ -433,7 +433,7 @@ func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogT return matches } -func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] { +nonisolated func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] { let nsText = text as NSString let strongMarkers = [ "fatal error", diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index 8b5fc03..fffaba4 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -13,6 +13,8 @@ struct TicketListView: View { @State private var showCreateTicketSheet = false @State private var createdTicket: TicketSummary? @State private var labelEditorTicket: LabelEditorTicket? + @State private var showLabelFilterSheet = false + @State private var showSaveFilterSheet = false @State private var showTrackerEditor = false @State private var showTrackerACLs = false @State private var showTrackerLabels = false @@ -86,6 +88,18 @@ struct TicketListView: View { .presentationDetents([.medium]) } } + .sheet(isPresented: $showLabelFilterSheet) { + if let viewModel { + TicketFilterLabelsSheet(viewModel: viewModel) + .presentationDetents([.medium, .large]) + } + } + .sheet(isPresented: $showSaveFilterSheet) { + if let viewModel { + SaveTicketFilterSheet(viewModel: viewModel) + .presentationDetents([.height(220)]) + } + } .sheet(isPresented: $showTrackerEditor) { if let trackerManagementViewModel { TrackerEditorSheet( @@ -169,6 +183,7 @@ struct TicketListView: View { viewModel = vm trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client) await vm.loadTickets() + await vm.loadTrackerLabels() } } } @@ -178,14 +193,33 @@ struct TicketListView: View { @Bindable var vm = viewModel List { - // Filter picker Section { - Picker("Filter", selection: $vm.filter) { - ForEach(TicketFilter.allCases, id: \.self) { filter in - Text(filter.rawValue).tag(filter) + VStack(alignment: .leading, spacing: 12) { + Picker("Filter", selection: $vm.filter) { + ForEach(TicketFilter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + + TicketQuickFilterBar( + selectedLabels: viewModel.selectedLabels, + savedFilters: viewModel.savedFilters, + activeSavedFilterID: viewModel.activeSavedFilterID, + canSaveCurrentFilter: viewModel.hasCustomFilterSelection + ) { + showLabelFilterSheet = true + } onSaveFilter: { + showSaveFilterSheet = true + } onResetFilters: { + vm.resetFilters() + } onApplySavedFilter: { savedFilter in + vm.applySavedFilter(savedFilter) + } onDeleteSavedFilter: { savedFilter in + vm.deleteSavedFilter(savedFilter) } } - .pickerStyle(.segmented) + .padding(.vertical, 4) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) } @@ -250,7 +284,7 @@ struct TicketListView: View { ContentUnavailableView( "No Tickets", systemImage: "ticket", - description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.") + description: Text(emptyStateDescription(for: viewModel)) ) } } @@ -263,6 +297,13 @@ struct TicketListView: View { } } + private func emptyStateDescription(for viewModel: TicketListViewModel) -> String { + if !viewModel.selectedLabelIDs.isEmpty { + return "No \(viewModel.filter.rawValue.lowercased()) tickets found for the selected labels." + } + return "No \(viewModel.filter.rawValue.lowercased()) tickets found." + } + private var trackerActionsMenu: some View { Menu { Button { @@ -497,6 +538,198 @@ private struct TicketLabelsSheet: View { } } +private struct TicketQuickFilterBar: View { + let selectedLabels: [TicketLabel] + let savedFilters: [SavedTicketFilter] + let activeSavedFilterID: SavedTicketFilter.ID? + let canSaveCurrentFilter: Bool + let onShowLabels: () -> Void + let onSaveFilter: () -> Void + let onResetFilters: () -> Void + let onApplySavedFilter: (SavedTicketFilter) -> Void + let onDeleteSavedFilter: (SavedTicketFilter) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Button(action: onShowLabels) { + Label(labelButtonTitle, systemImage: "tag") + .font(.caption.weight(.medium)) + } + .buttonStyle(.bordered) + + Button(action: onSaveFilter) { + Label("Save Filter", systemImage: "square.and.arrow.down") + .font(.caption.weight(.medium)) + } + .buttonStyle(.bordered) + .disabled(!canSaveCurrentFilter) + + if canSaveCurrentFilter { + Button("Reset", action: onResetFilters) + .font(.caption.weight(.medium)) + .buttonStyle(.bordered) + } + } + + if !selectedLabels.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(selectedLabels) { label in + LabelPill(label: label) + } + } + } + } + + if !savedFilters.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(savedFilters) { savedFilter in + Button { + onApplySavedFilter(savedFilter) + } label: { + HStack(spacing: 6) { + Image(systemName: activeSavedFilterID == savedFilter.id ? "checkmark.circle.fill" : "line.3.horizontal.decrease.circle") + .imageScale(.small) + Text(savedFilter.name) + .lineLimit(1) + } + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .foregroundStyle(activeSavedFilterID == savedFilter.id ? Color.accentColor : Color.primary) + .background( + activeSavedFilterID == savedFilter.id ? + Color.accentColor.opacity(0.14) : + Color(.secondarySystemFill), + in: Capsule() + ) + } + .contextMenu { + Button(role: .destructive) { + onDeleteSavedFilter(savedFilter) + } label: { + Label("Delete Filter", systemImage: "trash") + } + } + } + } + } + } + } + } + + private var labelButtonTitle: String { + selectedLabels.isEmpty ? "Labels" : "Labels (\(selectedLabels.count))" + } +} + +private struct TicketFilterLabelsSheet: View { + let viewModel: TicketListViewModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Group { + if viewModel.availableLabels.isEmpty { + ContentUnavailableView( + "No Labels", + systemImage: "tag", + description: Text("This tracker has no labels available for filtering yet.") + ) + } else { + List { + if !viewModel.selectedLabels.isEmpty { + Section("Selected") { + FlowLayout(spacing: 6) { + ForEach(viewModel.selectedLabels) { label in + LabelPill(label: label) + } + } + .padding(.vertical, 4) + } + } + + Section("Labels") { + ForEach(viewModel.availableLabels) { label in + Button { + viewModel.toggleLabelSelection(label) + } label: { + HStack { + LabelPill(label: label) + Spacer() + if viewModel.selectedLabelIDs.contains(label.id) { + Image(systemName: "checkmark") + .foregroundStyle(.blue) + } + } + } + .buttonStyle(.plain) + } + } + } + } + } + .navigationTitle("Filter Labels") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + if !viewModel.selectedLabelIDs.isEmpty { + ToolbarItem(placement: .confirmationAction) { + Button("Clear") { viewModel.clearLabelSelection() } + } + } + } + .task { + if viewModel.availableLabels.isEmpty { + await viewModel.loadTrackerLabels() + } + } + } + } +} + +private struct SaveTicketFilterSheet: View { + let viewModel: TicketListViewModel + + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init(viewModel: TicketListViewModel) { + self.viewModel = viewModel + self._name = State(initialValue: viewModel.suggestedSavedFilterName) + } + + var body: some View { + NavigationStack { + Form { + Section("Name") { + TextField("Filter name", text: $name) + .textInputAutocapitalization(.words) + } + } + .navigationTitle("Save Filter") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + viewModel.saveCurrentFilter(named: name) + dismiss() + } + .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } +} + private struct TicketListLabelToggleRow: View { let label: TicketLabel let isApplied: Bool diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift index 9f2fe79..3b1310d 100644 --- a/Hutch/Views/Tickets/TicketListViewModel.swift +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -55,7 +55,7 @@ private struct MutationEventRef: Decodable, Sendable { // MARK: - Filter -enum TicketFilter: String, CaseIterable, Sendable { +enum TicketFilter: String, CaseIterable, Codable, Sendable { case open = "Open" case resolved = "Resolved" case all = "All" @@ -77,32 +77,46 @@ final class TicketListViewModel { private(set) var isCreatingTicket = false private(set) var isPerformingAction = false private(set) var trackerLabels: [TicketLabel] = [] + private(set) var savedFilters: [SavedTicketFilter] var error: String? var filter: TicketFilter = .open { didSet { - UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey) + persistFilterState() + } + } + var selectedLabelIDs: Set<Int> = [] { + didSet { + persistFilterState() } } var searchText = "" + private(set) var activeSavedFilterID: SavedTicketFilter.ID? private var cursor: String? private var hasMore = true private let client: SRHTClient - - private var filterDefaultsKey: String { - "ticketFilter_\(trackerRid)" - } - - init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) { + private let defaults: UserDefaults + + init( + ownerUsername: String, + trackerName: String, + trackerId: Int, + trackerRid: String, + client: SRHTClient, + defaults: UserDefaults = .standard + ) { self.ownerUsername = ownerUsername self.trackerName = trackerName self.trackerId = trackerId self.trackerRid = trackerRid self.client = client - if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey), - let restored = TicketFilter(rawValue: raw) { - self.filter = restored - } + self.defaults = defaults + + let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults) + self.filter = restoredState.status + self.selectedLabelIDs = Set(restoredState.labelIDs) + self.savedFilters = TicketSavedFilterStore.loadSavedFilters(for: trackerRid, defaults: defaults) + self.activeSavedFilterID = self.savedFilters.first(where: { $0.state == restoredState })?.id } // MARK: - Query @@ -186,25 +200,43 @@ final class TicketListViewModel { // MARK: - Computed - /// Tickets filtered by the selected status filter. - var filteredTickets: [TicketSummary] { - let statusFiltered: [TicketSummary] - switch filter { - case .open: - statusFiltered = tickets.filter { $0.status.isOpen } - case .resolved: - statusFiltered = tickets.filter { !$0.status.isOpen } - case .all: - statusFiltered = tickets + var currentFilterState: TicketListFilterState { + TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs)) + } + + var availableLabels: [TicketLabel] { + let combinedLabels = trackerLabels + tickets.flatMap(\.labels) + let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in + partialResult[label.id] = label } - let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return statusFiltered } - return statusFiltered.filter { - String($0.id).contains(q) || - $0.title.lowercased().contains(q) || - $0.submitter.canonicalName.lowercased().contains(q) || - $0.labels.contains { $0.name.lowercased().contains(q) } + return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + var selectedLabels: [TicketLabel] { + availableLabels.filter { selectedLabelIDs.contains($0.id) } + } + + var suggestedSavedFilterName: String { + let labelNames = selectedLabels.map(\.name).sorted() + var components: [String] = [] + + if filter != .open || !labelNames.isEmpty { + components.append(filter.rawValue) + } + if !labelNames.isEmpty { + components.append(labelNames.joined(separator: ", ")) } + + return components.isEmpty ? "Open Tickets" : components.joined(separator: " • ") + } + + var hasCustomFilterSelection: Bool { + !currentFilterState.isDefault + } + + /// Tickets filtered by the selected status and label filters. + var filteredTickets: [TicketSummary] { + Self.filterTickets(tickets, state: currentFilterState, query: searchText) } // MARK: - Public API @@ -401,6 +433,54 @@ final class TicketListViewModel { } } + func toggleLabelSelection(_ label: TicketLabel) { + if selectedLabelIDs.contains(label.id) { + selectedLabelIDs.remove(label.id) + } else { + selectedLabelIDs.insert(label.id) + } + } + + func clearLabelSelection() { + selectedLabelIDs = [] + } + + func resetFilters() { + filter = .open + selectedLabelIDs = [] + } + + func applySavedFilter(_ savedFilter: SavedTicketFilter) { + filter = savedFilter.state.status + selectedLabelIDs = Set(savedFilter.state.labelIDs) + activeSavedFilterID = savedFilter.id + } + + func saveCurrentFilter(named name: String) { + guard let savedFilter = TicketSavedFilterStore.saveFilter( + named: name, + state: currentFilterState, + for: trackerRid, + defaults: defaults + ) else { + return + } + + savedFilters.removeAll { + $0.name.compare(name, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + savedFilters.insert(savedFilter, at: 0) + activeSavedFilterID = savedFilter.id + } + + func deleteSavedFilter(_ savedFilter: SavedTicketFilter) { + TicketSavedFilterStore.deleteFilter(id: savedFilter.id, for: trackerRid, defaults: defaults) + savedFilters.removeAll { $0.id == savedFilter.id } + if activeSavedFilterID == savedFilter.id { + activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id + } + } + func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async { guard !isPerformingAction else { return } isPerformingAction = true @@ -569,4 +649,44 @@ final class TicketListViewModel { assignees: ticket.assignees ) } + + private func persistFilterState() { + TicketSavedFilterStore.saveCurrentState(currentFilterState, for: trackerRid, defaults: defaults) + activeSavedFilterID = savedFilters.first(where: { $0.state == currentFilterState })?.id + } + + static func filterTickets( + _ tickets: [TicketSummary], + state: TicketListFilterState, + query: String + ) -> [TicketSummary] { + let statusFiltered: [TicketSummary] + switch state.status { + case .open: + statusFiltered = tickets.filter { $0.status.isOpen } + case .resolved: + statusFiltered = tickets.filter { !$0.status.isOpen } + case .all: + statusFiltered = tickets + } + + let labelFiltered: [TicketSummary] + if state.labelIDs.isEmpty { + labelFiltered = statusFiltered + } else { + let selectedLabelIDs = Set(state.labelIDs) + labelFiltered = statusFiltered.filter { ticket in + !selectedLabelIDs.isDisjoint(with: ticket.labels.map(\.id)) + } + } + + let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !q.isEmpty else { return labelFiltered } + return labelFiltered.filter { + String($0.id).contains(q) || + $0.title.lowercased().contains(q) || + $0.submitter.canonicalName.lowercased().contains(q) || + $0.labels.contains { $0.name.lowercased().contains(q) } + } + } } diff --git a/Hutch/Views/Tickets/TicketSavedFilterStore.swift b/Hutch/Views/Tickets/TicketSavedFilterStore.swift new file mode 100644 index 0000000..f5e5946 --- /dev/null +++ b/Hutch/Views/Tickets/TicketSavedFilterStore.swift @@ -0,0 +1,142 @@ +import Foundation + +struct TicketListFilterState: Codable, Hashable, Sendable { + var status: TicketFilter + var labelIDs: [Int] + + init(status: TicketFilter = .open, labelIDs: [Int] = []) { + self.status = status + self.labelIDs = Array(Set(labelIDs)).sorted() + } + + var isDefault: Bool { + status == .open && labelIDs.isEmpty + } +} + +struct SavedTicketFilter: Codable, Hashable, Identifiable, Sendable { + let id: UUID + let name: String + let state: TicketListFilterState + let createdAt: Date +} + +enum TicketSavedFilterStore { + static func loadCurrentState( + for trackerID: String, + defaults: UserDefaults = .standard + ) -> TicketListFilterState { + let allStates = loadStates(defaults: defaults) + if let savedState = allStates[trackerID] { + return savedState + } + + if let legacyRawValue = defaults.string(forKey: legacyStatusKey(for: trackerID)), + let status = TicketFilter(rawValue: legacyRawValue) { + return TicketListFilterState(status: status) + } + + return TicketListFilterState() + } + + static func saveCurrentState( + _ state: TicketListFilterState, + for trackerID: String, + defaults: UserDefaults = .standard + ) { + var allStates = loadStates(defaults: defaults) + allStates[trackerID] = state + save(allStates, key: AppStorageKeys.ticketFilterState, defaults: defaults) + defaults.removeObject(forKey: legacyStatusKey(for: trackerID)) + } + + static func loadSavedFilters( + for trackerID: String, + defaults: UserDefaults = .standard + ) -> [SavedTicketFilter] { + let allFilters: [String: [SavedTicketFilter]] = loadDictionary( + key: AppStorageKeys.ticketSavedFilters, + defaults: defaults + ) + return allFilters[trackerID] ?? [] + } + + static func saveFilter( + named name: String, + state: TicketListFilterState, + for trackerID: String, + defaults: UserDefaults = .standard, + now: Date = .now + ) -> SavedTicketFilter? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return nil } + + var allFilters: [String: [SavedTicketFilter]] = loadDictionary( + key: AppStorageKeys.ticketSavedFilters, + defaults: defaults + ) + var trackerFilters = allFilters[trackerID] ?? [] + trackerFilters.removeAll { + $0.name.compare(trimmedName, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame + } + + let savedFilter = SavedTicketFilter( + id: UUID(), + name: trimmedName, + state: state, + createdAt: now + ) + trackerFilters.insert(savedFilter, at: 0) + allFilters[trackerID] = trackerFilters + save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults) + return savedFilter + } + + static func deleteFilter( + id: SavedTicketFilter.ID, + for trackerID: String, + defaults: UserDefaults = .standard + ) { + var allFilters: [String: [SavedTicketFilter]] = loadDictionary( + key: AppStorageKeys.ticketSavedFilters, + defaults: defaults + ) + var trackerFilters = allFilters[trackerID] ?? [] + trackerFilters.removeAll { $0.id == id } + allFilters[trackerID] = trackerFilters + save(allFilters, key: AppStorageKeys.ticketSavedFilters, defaults: defaults) + } + + private static func loadStates(defaults: UserDefaults) -> [String: TicketListFilterState] { + loadDictionary(key: AppStorageKeys.ticketFilterState, defaults: defaults) + } + + private static func loadDictionary<T: Decodable>( + key: String, + defaults: UserDefaults + ) -> [String: T] { + guard let data = defaults.data(forKey: key) else { + return [:] + } + + do { + return try JSONDecoder().decode([String: T].self, from: data) + } catch { + defaults.removeObject(forKey: key) + return [:] + } + } + + private static func save<T: Encodable>( + _ value: [String: T], + key: String, + defaults: UserDefaults + ) { + guard let data = try? JSONEncoder().encode(value) else { return } + defaults.set(data, forKey: key) + } + + private static func legacyStatusKey(for trackerID: String) -> String { + "ticketFilter_\(trackerID)" + } +} diff --git a/HutchTests/TicketListViewModelTests.swift b/HutchTests/TicketListViewModelTests.swift index 23be24c..0f42bf8 100644 --- a/HutchTests/TicketListViewModelTests.swift +++ b/HutchTests/TicketListViewModelTests.swift @@ -11,7 +11,11 @@ struct TicketListViewModelTests { makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: []) ] - let filtered = filterTickets(tickets, filter: .open, query: "") + let filtered = filterTickets( + tickets, + state: TicketListFilterState(status: .open), + query: "" + ) #expect(filtered.map(\.id) == [1]) } @@ -23,8 +27,16 @@ struct TicketListViewModelTests { makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: []) ] - let titleMatches = filterTickets(tickets, filter: .all, query: "settings") - let idMatches = filterTickets(tickets, filter: .all, query: "42") + let titleMatches = filterTickets( + tickets, + state: TicketListFilterState(status: .all), + query: "settings" + ) + let idMatches = filterTickets( + tickets, + state: TicketListFilterState(status: .all), + query: "42" + ) #expect(titleMatches.map(\.id) == [99]) #expect(idMatches.map(\.id) == [42]) @@ -37,14 +49,39 @@ struct TicketListViewModelTests { makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")]) ] - let submitterMatches = filterTickets(tickets, filter: .all, query: "~triage") - let labelMatches = filterTickets(tickets, filter: .all, query: "bug") + let submitterMatches = filterTickets( + tickets, + state: TicketListFilterState(status: .all), + query: "~triage" + ) + let labelMatches = filterTickets( + tickets, + state: TicketListFilterState(status: .all), + query: "bug" + ) #expect(submitterMatches.map(\.id) == [2]) #expect(labelMatches.map(\.id) == [1]) } @Test + func filteredTicketsMatchesAnySelectedLabel() { + let tickets = [ + makeTicket(id: 1, title: "Crash on launch", status: .reported, submitter: "~owner", labels: [makeLabel(id: 1, name: "bug")]), + makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")]), + makeTicket(id: 3, title: "Unlabeled", status: .reported, submitter: "~owner", labels: []) + ] + + let filtered = filterTickets( + tickets, + state: TicketListFilterState(status: .all, labelIDs: [2, 3]), + query: "" + ) + + #expect(filtered.map(\.id) == [2]) + } + + @Test @MainActor func resolveTicketInputHasCorrectStatusAndDefaultResolution() { let input: [String: any Sendable] = [ @@ -65,25 +102,12 @@ struct TicketListViewModelTests { #expect(input["resolution"] == nil) } - private func filterTickets(_ tickets: [TicketSummary], filter: TicketFilter, query: String) -> [TicketSummary] { - let statusFiltered: [TicketSummary] - switch filter { - case .open: - statusFiltered = tickets.filter { $0.status.isOpen } - case .resolved: - statusFiltered = tickets.filter { !$0.status.isOpen } - case .all: - statusFiltered = tickets - } - - let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !q.isEmpty else { return statusFiltered } - return statusFiltered.filter { - String($0.id).contains(q) || - $0.title.lowercased().contains(q) || - $0.submitter.canonicalName.lowercased().contains(q) || - $0.labels.contains { $0.name.lowercased().contains(q) } - } + private func filterTickets( + _ tickets: [TicketSummary], + state: TicketListFilterState, + query: String + ) -> [TicketSummary] { + TicketListViewModel.filterTickets(tickets, state: state, query: query) } private func makeTicket( diff --git a/HutchTests/TicketSavedFilterStoreTests.swift b/HutchTests/TicketSavedFilterStoreTests.swift new file mode 100644 index 0000000..2ff64e6 --- /dev/null +++ b/HutchTests/TicketSavedFilterStoreTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import Hutch + +struct TicketSavedFilterStoreTests { + + @Test + func storesCurrentFilterStatePerTracker() { + let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + TicketSavedFilterStore.saveCurrentState( + TicketListFilterState(status: .all, labelIDs: [4, 1]), + for: "tracker-a", + defaults: defaults + ) + TicketSavedFilterStore.saveCurrentState( + TicketListFilterState(status: .resolved, labelIDs: [9]), + for: "tracker-b", + defaults: defaults + ) + + #expect( + TicketSavedFilterStore.loadCurrentState(for: "tracker-a", defaults: defaults) == + TicketListFilterState(status: .all, labelIDs: [1, 4]) + ) + #expect( + TicketSavedFilterStore.loadCurrentState(for: "tracker-b", defaults: defaults) == + TicketListFilterState(status: .resolved, labelIDs: [9]) + ) + } + + @Test + func savesNamedFiltersPerTrackerAndReplacesDuplicateNames() { + let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + _ = TicketSavedFilterStore.saveFilter( + named: "Bugs", + state: TicketListFilterState(status: .open, labelIDs: [1]), + for: "tracker-a", + defaults: defaults, + now: Date(timeIntervalSince1970: 100) + ) + _ = TicketSavedFilterStore.saveFilter( + named: "bugs", + state: TicketListFilterState(status: .resolved, labelIDs: [2]), + for: "tracker-a", + defaults: defaults, + now: Date(timeIntervalSince1970: 200) + ) + _ = TicketSavedFilterStore.saveFilter( + named: "Needs Info", + state: TicketListFilterState(status: .all, labelIDs: [3]), + for: "tracker-b", + defaults: defaults, + now: Date(timeIntervalSince1970: 300) + ) + + let trackerAFilters = TicketSavedFilterStore.loadSavedFilters(for: "tracker-a", defaults: defaults) + let trackerBFilters = TicketSavedFilterStore.loadSavedFilters(for: "tracker-b", defaults: defaults) + + #expect(trackerAFilters.count == 1) + #expect(trackerAFilters.first?.name == "bugs") + #expect(trackerAFilters.first?.state == TicketListFilterState(status: .resolved, labelIDs: [2])) + #expect(trackerBFilters.map(\.name) == ["Needs Info"]) + } + + @Test + func deletesSavedFilter() { + let suiteName = "TicketSavedFilterStoreTests-\(#function)-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let savedFilter = TicketSavedFilterStore.saveFilter( + named: "Bugs", + state: TicketListFilterState(status: .open, labelIDs: [1]), + for: "tracker-a", + defaults: defaults + ) + + TicketSavedFilterStore.deleteFilter(id: savedFilter!.id, for: "tracker-a", defaults: defaults) + + #expect(TicketSavedFilterStore.loadSavedFilters(for: "tracker-a", defaults: defaults).isEmpty) + } +} |
