Commit 30bac29e44

30bac29e443a9da39cbf16e8315896a86351ef2b

parent: 9f42c10f10

Unsigned

cmc <hello@cleberg.net> · 2026-04-13 02:33 UTC

feat: add ticket saved filters and label filtering

Implements: https://todo.sr.ht/~ccleberg/hutch/32
Implements: https://todo.sr.ht/~ccleberg/hutch/33

Layout: unified · split

Hutch/App/AppStorageKeys.swift +2
@@ -14,6 +14,8 @@ enum AppStorageKeys {
1414 static let homeBuildsExpanded = "homeBuildsExpanded"
1515 static let buildsAutoRefreshInterval = "buildsAutoRefreshInterval"
1616 static let buildsRepoFilter = "buildsRepoFilter"
17 static let ticketFilterState = "ticketFilterState"
18 static let ticketSavedFilters = "ticketSavedFilters"
1719 static let appTheme = "appTheme"
1820 static let displayDensity = "displayDensity"
1921}
Hutch/Views/Builds/BuildTaskLogView.swift +2 −2
@@ -407,7 +407,7 @@ private struct LogScrollTarget: Equatable {
407407 let range: LogTextRange
408408}
409409
410func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
410nonisolated func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogTextRange] {
411411 let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
412412 guard !trimmedQuery.isEmpty else { return [] }
413413
@@ -433,7 +433,7 @@ func logMatchRanges(in text: String, query: String, limit: Int = 2_000) -> [LogT
433433 return matches
434434}
435435
436func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
436nonisolated func detectLogAnchors(in text: String, limit: Int = 24) -> [LogAnchor] {
437437 let nsText = text as NSString
438438 let strongMarkers = [
439439 "fatal error",
Hutch/Views/Tickets/TicketListView.swift +239 −6
@@ -13,6 +13,8 @@ struct TicketListView: View {
1313 @State private var showCreateTicketSheet = false
1414 @State private var createdTicket: TicketSummary?
1515 @State private var labelEditorTicket: LabelEditorTicket?
16 @State private var showLabelFilterSheet = false
17 @State private var showSaveFilterSheet = false
1618 @State private var showTrackerEditor = false
1719 @State private var showTrackerACLs = false
1820 @State private var showTrackerLabels = false
@@ -86,6 +88,18 @@ struct TicketListView: View {
8688 .presentationDetents([.medium])
8789 }
8890 }
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 }
89103 .sheet(isPresented: $showTrackerEditor) {
90104 if let trackerManagementViewModel {
91105 TrackerEditorSheet(
@@ -169,6 +183,7 @@ struct TicketListView: View {
169183 viewModel = vm
170184 trackerManagementViewModel = TrackerManagementViewModel(tracker: tracker, client: appState.client)
171185 await vm.loadTickets()
186 await vm.loadTrackerLabels()
172187 }
173188 }
174189 }
@@ -178,14 +193,33 @@ struct TicketListView: View {
178193 @Bindable var vm = viewModel
179194
180195 List {
181 // Filter picker
182196 Section {
183 Picker("Filter", selection: $vm.filter) {
184 ForEach(TicketFilter.allCases, id: \.self) { filter in
185 Text(filter.rawValue).tag(filter)
197 VStack(alignment: .leading, spacing: 12) {
198 Picker("Filter", selection: $vm.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)
186220 }
187221 }
188 .pickerStyle(.segmented)
222 .padding(.vertical, 4)
189223 .listRowBackground(Color.clear)
190224 .listRowInsets(EdgeInsets())
191225 }
@@ -250,7 +284,7 @@ struct TicketListView: View {
250284 ContentUnavailableView(
251285 "No Tickets",
252286 systemImage: "ticket",
253 description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
287 description: Text(emptyStateDescription(for: viewModel))
254288 )
255289 }
256290 }
@@ -263,6 +297,13 @@ struct TicketListView: View {
263297 }
264298 }
265299
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
266307 private var trackerActionsMenu: some View {
267308 Menu {
268309 Button {
@@ -497,6 +538,198 @@ private struct TicketLabelsSheet: View {
497538 }
498539}
499540
541private 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
628private 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
696private 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
500733private struct TicketListLabelToggleRow: View {
501734 let label: TicketLabel
502735 let isApplied: Bool
Hutch/Views/Tickets/TicketListViewModel.swift +149 −29
@@ -55,7 +55,7 @@ private struct MutationEventRef: Decodable, Sendable {
5555
5656// MARK: - Filter
5757
58enum TicketFilter: String, CaseIterable, Sendable {
58enum TicketFilter: String, CaseIterable, Codable, Sendable {
5959 case open = "Open"
6060 case resolved = "Resolved"
6161 case all = "All"
@@ -77,32 +77,46 @@ final class TicketListViewModel {
7777 private(set) var isCreatingTicket = false
7878 private(set) var isPerformingAction = false
7979 private(set) var trackerLabels: [TicketLabel] = []
80 private(set) var savedFilters: [SavedTicketFilter]
8081 var error: String?
8182 var filter: TicketFilter = .open {
8283 didSet {
83 UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey)
84 persistFilterState()
85 }
86 }
87 var selectedLabelIDs: Set<Int> = [] {
88 didSet {
89 persistFilterState()
8490 }
8591 }
8692 var searchText = ""
93 private(set) var activeSavedFilterID: SavedTicketFilter.ID?
8794
8895 private var cursor: String?
8996 private var hasMore = true
9097 private let client: SRHTClient
91
92 private var filterDefaultsKey: String {
93 "ticketFilter_\(trackerRid)"
94 }
95
96 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) {
98 private let defaults: UserDefaults
99
100 init(
101 ownerUsername: String,
102 trackerName: String,
103 trackerId: Int,
104 trackerRid: String,
105 client: SRHTClient,
106 defaults: UserDefaults = .standard
107 ) {
97108 self.ownerUsername = ownerUsername
98109 self.trackerName = trackerName
99110 self.trackerId = trackerId
100111 self.trackerRid = trackerRid
101112 self.client = client
102 if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey),
103 let restored = TicketFilter(rawValue: raw) {
104 self.filter = restored
105 }
113 self.defaults = defaults
114
115 let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
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
106120 }
107121
108122 // MARK: - Query
@@ -186,25 +200,43 @@ final class TicketListViewModel {
186200
187201 // MARK: - Computed
188202
189 /// Tickets filtered by the selected status filter.
190 var filteredTickets: [TicketSummary] {
191 let statusFiltered: [TicketSummary]
192 switch filter {
193 case .open:
194 statusFiltered = tickets.filter { $0.status.isOpen }
195 case .resolved:
196 statusFiltered = tickets.filter { !$0.status.isOpen }
197 case .all:
198 statusFiltered = tickets
203 var currentFilterState: TicketListFilterState {
204 TicketListFilterState(status: filter, labelIDs: Array(selectedLabelIDs))
205 }
206
207 var availableLabels: [TicketLabel] {
208 let combinedLabels = trackerLabels + tickets.flatMap(\.labels)
209 let deduplicated = combinedLabels.reduce(into: [Int: TicketLabel]()) { partialResult, label in
210 partialResult[label.id] = label
199211 }
200 let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
201 guard !q.isEmpty else { return statusFiltered }
202 return statusFiltered.filter {
203 String($0.id).contains(q) ||
204 $0.title.lowercased().contains(q) ||
205 $0.submitter.canonicalName.lowercased().contains(q) ||
206 $0.labels.contains { $0.name.lowercased().contains(q) }
212 return deduplicated.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
213 }
214
215 var selectedLabels: [TicketLabel] {
216 availableLabels.filter { selectedLabelIDs.contains($0.id) }
217 }
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: ", "))
207228 }
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)
208240 }
209241
210242 // MARK: - Public API
@@ -401,6 +433,54 @@ final class TicketListViewModel {
401433 }
402434 }
403435
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
404484 func labelTicket(_ ticket: TicketSummary, label: TicketLabel) async {
405485 guard !isPerformingAction else { return }
406486 isPerformingAction = true
@@ -569,4 +649,44 @@ final class TicketListViewModel {
569649 assignees: ticket.assignees
570650 )
571651 }
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 }
572692}
Hutch/Views/Tickets/TicketSavedFilterStore.swift added +142
@@ -0,0 +1,142 @@
1import Foundation
2
3struct 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
17struct SavedTicketFilter: Codable, Hashable, Identifiable, Sendable {
18 let id: UUID
19 let name: String
20 let state: TicketListFilterState
21 let createdAt: Date
22}
23
24enum 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 {
1111 makeTicket(id: 2, title: "Already fixed", status: .resolved, submitter: "~owner", labels: [])
1212 ]
1313
14 let filtered = filterTickets(tickets, filter: .open, query: "")
14 let filtered = filterTickets(
15 tickets,
16 state: TicketListFilterState(status: .open),
17 query: ""
18 )
1519
1620 #expect(filtered.map(\.id) == [1])
1721 }
@@ -23,8 +27,16 @@ struct TicketListViewModelTests {
2327 makeTicket(id: 99, title: "Settings polish", status: .reported, submitter: "~owner", labels: [])
2428 ]
2529
26 let titleMatches = filterTickets(tickets, filter: .all, query: "settings")
27 let idMatches = filterTickets(tickets, filter: .all, query: "42")
30 let titleMatches = filterTickets(
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 )
2840
2941 #expect(titleMatches.map(\.id) == [99])
3042 #expect(idMatches.map(\.id) == [42])
@@ -37,13 +49,38 @@ struct TicketListViewModelTests {
3749 makeTicket(id: 2, title: "Needs triage", status: .reported, submitter: "~triage", labels: [makeLabel(id: 2, name: "needs-info")])
3850 ]
3951
40 let submitterMatches = filterTickets(tickets, filter: .all, query: "~triage")
41 let labelMatches = filterTickets(tickets, filter: .all, query: "bug")
52 let submitterMatches = filterTickets(
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 )
4262
4363 #expect(submitterMatches.map(\.id) == [2])
4464 #expect(labelMatches.map(\.id) == [1])
4565 }
4666
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
4784 @Test
4885 @MainActor
4986 func resolveTicketInputHasCorrectStatusAndDefaultResolution() {
@@ -65,25 +102,12 @@ struct TicketListViewModelTests {
65102 #expect(input["resolution"] == nil)
66103 }
67104
68 private func filterTickets(_ tickets: [TicketSummary], filter: TicketFilter, query: String) -> [TicketSummary] {
69 let statusFiltered: [TicketSummary]
70 switch filter {
71 case .open:
72 statusFiltered = tickets.filter { $0.status.isOpen }
73 case .resolved:
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 }
105 private func filterTickets(
106 _ tickets: [TicketSummary],
107 state: TicketListFilterState,
108 query: String
109 ) -> [TicketSummary] {
110 TicketListViewModel.filterTickets(tickets, state: state, query: query)
87111 }
88112
89113 private func makeTicket(
HutchTests/TicketSavedFilterStoreTests.swift added +88
@@ -0,0 +1,88 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5struct 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}