summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-12 22:52:33 -0500
committerChristian Cleberg <[email protected]>2026-04-12 22:52:33 -0500
commit72f860e0e171f6aac4e7d896e7a571147dbcb5c5 (patch)
treec62c9c9325292f65e6f72372a38b35760cc937b1 /Hutch/Views/Tickets
parent576bb6c2432a15f5138771c06019b55fd2244e15 (diff)
downloadhutch-72f860e0e171f6aac4e7d896e7a571147dbcb5c5.tar.gz
hutch-72f860e0e171f6aac4e7d896e7a571147dbcb5c5.tar.bz2
hutch-72f860e0e171f6aac4e7d896e7a571147dbcb5c5.zip
feat: add search and recent queries
Implements: https://todo.sr.ht/~ccleberg/hutch/47 Implements: https://todo.sr.ht/~ccleberg/hutch/48
Diffstat (limited to 'Hutch/Views/Tickets')
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift25
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift38
2 files changed, 58 insertions, 5 deletions
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
index bd2236c..d2548d7 100644
--- a/Hutch/Views/Tickets/TicketListView.swift
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -328,8 +328,25 @@ struct TicketListView: View {
.searchable(
text: $vm.searchText,
placement: .navigationBarDrawer(displayMode: .always),
- prompt: "Search tickets"
+ prompt: "Search tickets in \(tracker.name)"
)
+ .searchSuggestions {
+ if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ RecentSearchSuggestions(
+ title: "Recent Ticket Searches",
+ entries: viewModel.recentSearches
+ ) { query in
+ vm.searchText = query
+ } onClear: {
+ viewModel.clearRecentSearches()
+ }
+ }
+ }
+ .onSubmit(of: .search) {
+ let query = viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty else { return }
+ viewModel.recordRecentSearch(query)
+ }
.overlay {
if viewModel.isLoading, viewModel.tickets.isEmpty {
SRHTLoadingStateView(message: "Loading tickets…")
@@ -341,7 +358,11 @@ struct TicketListView: View {
)
} else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
viewModel.filteredTickets.isEmpty {
- ContentUnavailableView.search(text: viewModel.searchText)
+ ContentUnavailableView(
+ "No Ticket Matches",
+ systemImage: "magnifyingglass",
+ description: Text("No tickets in \(tracker.name) matched “\(viewModel.searchText)”.")
+ )
} else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
ContentUnavailableView(
"No Tickets",
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
index a2e7135..e7cc565 100644
--- a/Hutch/Views/Tickets/TicketListViewModel.swift
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -74,6 +74,10 @@ enum TicketFilter: String, CaseIterable, Codable, Sendable {
@Observable
@MainActor
final class TicketListViewModel {
+ private static func searchHistoryScopeID(for trackerRid: String) -> String {
+ "tickets.\(trackerRid)"
+ }
+
let ownerUsername: String
let trackerName: String
let trackerId: Int
@@ -85,6 +89,7 @@ final class TicketListViewModel {
private(set) var isCreatingTicket = false
private(set) var isPerformingAction = false
private(set) var trackerLabels: [TicketLabel] = []
+ private(set) var recentSearches: [ScopedSearchHistoryEntry]
private(set) var savedFilters: [SavedTicketFilter]
private(set) var isSelectionMode = false
private(set) var selectedTicketIDs: Set<Int> = []
@@ -123,10 +128,15 @@ final class TicketListViewModel {
self.defaults = defaults
let restoredState = TicketSavedFilterStore.loadCurrentState(for: trackerRid, defaults: defaults)
+ let savedFilters = TicketSavedFilterStore.loadSavedFilters(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
+ self.savedFilters = savedFilters
+ self.activeSavedFilterID = savedFilters.first(where: { $0.state == restoredState })?.id
+ self.recentSearches = ScopedSearchHistoryStore.load(
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
}
// MARK: - Query
@@ -505,6 +515,26 @@ final class TicketListViewModel {
selectedLabelIDs = []
}
+ func recordRecentSearch(_ query: String) {
+ ScopedSearchHistoryStore.record(
+ query: query,
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
+ recentSearches = ScopedSearchHistoryStore.load(
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
+ }
+
+ func clearRecentSearches() {
+ ScopedSearchHistoryStore.clear(
+ scopeID: Self.searchHistoryScopeID(for: trackerRid),
+ defaults: defaults
+ )
+ recentSearches = []
+ }
+
func applySavedFilter(_ savedFilter: SavedTicketFilter) {
filter = savedFilter.state.status
selectedLabelIDs = Set(savedFilter.state.labelIDs)
@@ -938,7 +968,9 @@ final class TicketListViewModel {
String($0.id).contains(q) ||
$0.title.lowercased().contains(q) ||
$0.submitter.canonicalName.lowercased().contains(q) ||
- $0.labels.contains { $0.name.lowercased().contains(q) }
+ $0.labels.contains { $0.name.lowercased().contains(q) } ||
+ $0.assignees.contains { $0.canonicalName.lowercased().contains(q) } ||
+ $0.status.displayName.lowercased().contains(q)
}
}
}