summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch/App/AppStorageKeys.swift2
-rw-r--r--Hutch/Views/Builds/BuildTaskLogView.swift4
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift245
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift178
-rw-r--r--Hutch/Views/Tickets/TicketSavedFilterStore.swift142
-rw-r--r--HutchTests/TicketListViewModelTests.swift72
-rw-r--r--HutchTests/TicketSavedFilterStoreTests.swift88
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)
+ }
+}