Commit e3f8c2de0f
Unsigned
Layout: unified · split
Hutch/Views/Builds/BuildListView.swift +20 −1
| @@ -64,10 +64,22 @@ struct BuildListView: View { | |||
| 64 | @Bindable var vm = viewModel | 64 | @Bindable var vm = viewModel |
| 65 | 65 | ||
| 66 | List { | 66 | List { |
| 67 | ForEach(viewModel.jobs) { job in | 67 | ForEach(viewModel.filteredJobs) { job in |
| 68 | NavigationLink(value: job) { | 68 | NavigationLink(value: job) { |
| 69 | BuildRowView(job: job) | 69 | BuildRowView(job: job) |
| 70 | } | 70 | } |
| 71 | .swipeActions(edge: .leading, allowsFullSwipe: true) { | ||
| 72 | if swipeActionsEnabled, job.status.isCancellable { | ||
| 73 | Button { | ||
| 74 | Task { | ||
| 75 | await viewModel.cancelJob(job) | ||
| 76 | } | ||
| 77 | } label: { | ||
| 78 | Label("Cancel", systemImage: "xmark.circle") | ||
| 79 | } | ||
| 80 | .tint(.red) | ||
| 81 | } | ||
| 82 | } | ||
| 71 | .task { | 83 | .task { |
| 72 | await viewModel.loadMoreIfNeeded(currentItem: job) | 84 | await viewModel.loadMoreIfNeeded(currentItem: job) |
| 73 | } | 85 | } |
| @@ -83,6 +95,11 @@ struct BuildListView: View { | |||
| 83 | } | 95 | } |
| 84 | } | 96 | } |
| 85 | .listStyle(.plain) | 97 | .listStyle(.plain) |
| 98 | .searchable( | ||
| 99 | text: $vm.searchText, | ||
| 100 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 101 | prompt: "Search builds" | ||
| 102 | ) | ||
| 86 | .overlay { | 103 | .overlay { |
| 87 | if viewModel.isLoading, viewModel.jobs.isEmpty { | 104 | if viewModel.isLoading, viewModel.jobs.isEmpty { |
| 88 | SRHTLoadingStateView(message: "Loading builds…") | 105 | SRHTLoadingStateView(message: "Loading builds…") |
| @@ -92,6 +109,8 @@ struct BuildListView: View { | |||
| 92 | message: error, | 109 | message: error, |
| 93 | retryAction: { await viewModel.loadJobs() } | 110 | retryAction: { await viewModel.loadJobs() } |
| 94 | ) | 111 | ) |
| 112 | } else if !viewModel.jobs.isEmpty, viewModel.filteredJobs.isEmpty { | ||
| 113 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 95 | } else if viewModel.jobs.isEmpty, viewModel.error == nil { | 114 | } else if viewModel.jobs.isEmpty, viewModel.error == nil { |
| 96 | ContentUnavailableView( | 115 | ContentUnavailableView( |
| 97 | "No Builds", | 116 | "No Builds", |
Hutch/Views/Builds/BuildListViewModel.swift +18
| @@ -31,6 +31,7 @@ final class BuildListViewModel { | |||
| 31 | private(set) var isRefreshing = false | 31 | private(set) var isRefreshing = false |
| 32 | private(set) var isSubmitting = false | 32 | private(set) var isSubmitting = false |
| 33 | var error: String? | 33 | var error: String? |
| 34 | var searchText = "" | ||
| 34 | 35 | ||
| 35 | private var cursor: String? | 36 | private var cursor: String? |
| 36 | private var hasMore = true | 37 | private var hasMore = true |
| @@ -42,6 +43,17 @@ final class BuildListViewModel { | |||
| 42 | self.client = client | 43 | self.client = client |
| 43 | } | 44 | } |
| 44 | 45 | ||
| 46 | var filteredJobs: [JobSummary] { | ||
| 47 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 48 | guard !q.isEmpty else { return jobs } | ||
| 49 | return jobs.filter { | ||
| 50 | String($0.id).contains(q) || | ||
| 51 | $0.tags.contains { $0.lowercased().contains(q) } || | ||
| 52 | ($0.note?.lowercased().contains(q) == true) || | ||
| 53 | ($0.image?.lowercased().contains(q) == true) | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 45 | // MARK: - Query | 57 | // MARK: - Query |
| 46 | 58 | ||
| 47 | private static let query = """ | 59 | private static let query = """ |
| @@ -71,6 +83,12 @@ final class BuildListViewModel { | |||
| 71 | } | 83 | } |
| 72 | """ | 84 | """ |
| 73 | 85 | ||
| 86 | private static let cancelMutation = """ | ||
| 87 | mutation cancel($id: Int!) { | ||
| 88 | cancel(jobId: $id) { id } | ||
| 89 | } | ||
| 90 | """ | ||
| 91 | |||
| 74 | // MARK: - Public API | 92 | // MARK: - Public API |
| 75 | 93 | ||
| 76 | /// Fetch the first page of jobs. Shows cached data instantly if available, | 94 | /// Fetch the first page of jobs. Shows cached data instantly if available, |
Hutch/Views/Inbox/InboxView.swift +8 −1
| @@ -30,7 +30,7 @@ struct InboxView: View { | |||
| 30 | @Bindable var vm = viewModel | 30 | @Bindable var vm = viewModel |
| 31 | 31 | ||
| 32 | List { | 32 | List { |
| 33 | ForEach(viewModel.threads) { thread in | 33 | ForEach(viewModel.filteredThreads) { thread in |
| 34 | Button { | 34 | Button { |
| 35 | selectThread(thread) | 35 | selectThread(thread) |
| 36 | } label: { | 36 | } label: { |
| @@ -45,6 +45,11 @@ struct InboxView: View { | |||
| 45 | } | 45 | } |
| 46 | } | 46 | } |
| 47 | } | 47 | } |
| 48 | .searchable( | ||
| 49 | text: $vm.searchText, | ||
| 50 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 51 | prompt: "Search inbox" | ||
| 52 | ) | ||
| 48 | .listStyle(.plain) | 53 | .listStyle(.plain) |
| 49 | .overlay { | 54 | .overlay { |
| 50 | if viewModel.isLoading, viewModel.threads.isEmpty { | 55 | if viewModel.isLoading, viewModel.threads.isEmpty { |
| @@ -55,6 +60,8 @@ struct InboxView: View { | |||
| 55 | message: error, | 60 | message: error, |
| 56 | retryAction: { await viewModel.loadThreads() } | 61 | retryAction: { await viewModel.loadThreads() } |
| 57 | ) | 62 | ) |
| 63 | } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty { | ||
| 64 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 58 | } else if viewModel.threads.isEmpty, viewModel.error == nil { | 65 | } else if viewModel.threads.isEmpty, viewModel.error == nil { |
| 59 | ContentUnavailableView( | 66 | ContentUnavailableView( |
| 60 | "Inbox Zero", | 67 | "Inbox Zero", |
Hutch/Views/Inbox/InboxViewModel.swift +11
| @@ -62,6 +62,7 @@ final class InboxViewModel { | |||
| 62 | private(set) var threads: [InboxThreadSummary] = [] | 62 | private(set) var threads: [InboxThreadSummary] = [] |
| 63 | private(set) var isLoading = false | 63 | private(set) var isLoading = false |
| 64 | var error: String? | 64 | var error: String? |
| 65 | var searchText = "" | ||
| 65 | 66 | ||
| 66 | private let client: SRHTClient | 67 | private let client: SRHTClient |
| 67 | private let listThreadFetchLimit = 10 | 68 | private let listThreadFetchLimit = 10 |
| @@ -164,6 +165,16 @@ final class InboxViewModel { | |||
| 164 | threads.first(where: { $0.id == id }) | 165 | threads.first(where: { $0.id == id }) |
| 165 | } | 166 | } |
| 166 | 167 | ||
| 168 | var filteredThreads: [InboxThreadSummary] { | ||
| 169 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 170 | guard !q.isEmpty else { return threads } | ||
| 171 | return threads.filter { | ||
| 172 | $0.displaySubject.lowercased().contains(q) || | ||
| 173 | $0.listName.lowercased().contains(q) || | ||
| 174 | $0.latestSender.canonicalName.lowercased().contains(q) | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 167 | private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { | 178 | private func fetchSubscriptions() async throws -> [InboxActivitySubscription] { |
| 168 | var subscriptions: [InboxActivitySubscription] = [] | 179 | var subscriptions: [InboxActivitySubscription] = [] |
| 169 | var cursor: String? | 180 | var cursor: String? |
Hutch/Views/Lists/MailingListListView.swift +18 −1
| @@ -6,6 +6,7 @@ final class MailingListListViewModel { | |||
| 6 | private(set) var mailingLists: [InboxMailingListReference] = [] | 6 | private(set) var mailingLists: [InboxMailingListReference] = [] |
| 7 | private(set) var isLoading = false | 7 | private(set) var isLoading = false |
| 8 | var error: String? | 8 | var error: String? |
| 9 | var searchText = "" | ||
| 9 | 10 | ||
| 10 | private let client: SRHTClient | 11 | private let client: SRHTClient |
| 11 | 12 | ||
| @@ -31,6 +32,15 @@ final class MailingListListViewModel { | |||
| 31 | self.client = client | 32 | self.client = client |
| 32 | } | 33 | } |
| 33 | 34 | ||
| 35 | var filteredMailingLists: [InboxMailingListReference] { | ||
| 36 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 37 | guard !q.isEmpty else { return mailingLists } | ||
| 38 | return mailingLists.filter { | ||
| 39 | $0.name.lowercased().contains(q) || | ||
| 40 | $0.owner.canonicalName.lowercased().contains(q) | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 34 | func loadMailingLists() async { | 44 | func loadMailingLists() async { |
| 35 | guard !isLoading else { return } | 45 | guard !isLoading else { return } |
| 36 | isLoading = true | 46 | isLoading = true |
| @@ -120,7 +130,7 @@ struct MailingListListView: View { | |||
| 120 | @Bindable var vm = viewModel | 130 | @Bindable var vm = viewModel |
| 121 | 131 | ||
| 122 | List { | 132 | List { |
| 123 | ForEach(viewModel.mailingLists, id: \.rid) { mailingList in | 133 | ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in |
| 124 | NavigationLink(value: MoreRoute.mailingList(mailingList)) { | 134 | NavigationLink(value: MoreRoute.mailingList(mailingList)) { |
| 125 | VStack(alignment: .leading, spacing: 4) { | 135 | VStack(alignment: .leading, spacing: 4) { |
| 126 | Text(mailingList.name) | 136 | Text(mailingList.name) |
| @@ -134,6 +144,11 @@ struct MailingListListView: View { | |||
| 134 | } | 144 | } |
| 135 | } | 145 | } |
| 136 | .listStyle(.plain) | 146 | .listStyle(.plain) |
| 147 | .searchable( | ||
| 148 | text: $vm.searchText, | ||
| 149 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 150 | prompt: "Search lists" | ||
| 151 | ) | ||
| 137 | .overlay { | 152 | .overlay { |
| 138 | if viewModel.isLoading, viewModel.mailingLists.isEmpty { | 153 | if viewModel.isLoading, viewModel.mailingLists.isEmpty { |
| 139 | SRHTLoadingStateView(message: "Loading mailing lists…") | 154 | SRHTLoadingStateView(message: "Loading mailing lists…") |
| @@ -143,6 +158,8 @@ struct MailingListListView: View { | |||
| 143 | message: error, | 158 | message: error, |
| 144 | retryAction: { await viewModel.loadMailingLists() } | 159 | retryAction: { await viewModel.loadMailingLists() } |
| 145 | ) | 160 | ) |
| 161 | } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty { | ||
| 162 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 146 | } else if viewModel.mailingLists.isEmpty { | 163 | } else if viewModel.mailingLists.isEmpty { |
| 147 | ContentUnavailableView( | 164 | ContentUnavailableView( |
| 148 | "No Mailing Lists", | 165 | "No Mailing Lists", |
Hutch/Views/Pastes/PasteListView.swift +7
| @@ -87,6 +87,11 @@ struct PasteListView: View { | |||
| 87 | } | 87 | } |
| 88 | } | 88 | } |
| 89 | .listStyle(.plain) | 89 | .listStyle(.plain) |
| 90 | .searchable( | ||
| 91 | text: $vm.searchText, | ||
| 92 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 93 | prompt: "Search pastes" | ||
| 94 | ) | ||
| 90 | .overlay { | 95 | .overlay { |
| 91 | if viewModel.isLoading, viewModel.pastes.isEmpty { | 96 | if viewModel.isLoading, viewModel.pastes.isEmpty { |
| 92 | SRHTLoadingStateView(message: "Loading pastes…") | 97 | SRHTLoadingStateView(message: "Loading pastes…") |
| @@ -96,6 +101,8 @@ struct PasteListView: View { | |||
| 96 | message: error, | 101 | message: error, |
| 97 | retryAction: { await viewModel.loadPastes() } | 102 | retryAction: { await viewModel.loadPastes() } |
| 98 | ) | 103 | ) |
| 104 | } else if !viewModel.pastes.isEmpty, viewModel.filteredPastes.isEmpty { | ||
| 105 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 99 | } else if viewModel.pastes.isEmpty { | 106 | } else if viewModel.pastes.isEmpty { |
| 100 | ContentUnavailableView( | 107 | ContentUnavailableView( |
| 101 | "No Pastes", | 108 | "No Pastes", |
Hutch/Views/Pastes/PasteListViewModel.swift +12
| @@ -9,6 +9,7 @@ final class PasteListViewModel { | |||
| 9 | private(set) var isRefreshing = false | 9 | private(set) var isRefreshing = false |
| 10 | private(set) var isCreatingPaste = false | 10 | private(set) var isCreatingPaste = false |
| 11 | var error: String? | 11 | var error: String? |
| 12 | var searchText = "" | ||
| 12 | 13 | ||
| 13 | private var cursor: String? | 14 | private var cursor: String? |
| 14 | private var hasMore = true | 15 | private var hasMore = true |
| @@ -18,6 +19,17 @@ final class PasteListViewModel { | |||
| 18 | self.service = service | 19 | self.service = service |
| 19 | } | 20 | } |
| 20 | 21 | ||
| 22 | var filteredPastes: [Paste] { | ||
| 23 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 24 | guard !q.isEmpty else { return pastes } | ||
| 25 | return pastes.filter { | ||
| 26 | $0.files.contains { | ||
| 27 | ($0.filename?.lowercased().contains(q) == true) || | ||
| 28 | $0.hash.lowercased().hasPrefix(q) | ||
| 29 | } | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 21 | func loadPastes() async { | 33 | func loadPastes() async { |
| 22 | if pastes.isEmpty, let cached = service.loadCachedPastes() { | 34 | if pastes.isEmpty, let cached = service.loadCachedPastes() { |
| 23 | pastes = cached.results | 35 | pastes = cached.results |
Hutch/Views/Projects/ProjectMailingListView.swift +37 −1
| @@ -32,6 +32,7 @@ final class MailingListDetailViewModel { | |||
| 32 | private(set) var threads: [InboxThreadSummary] = [] | 32 | private(set) var threads: [InboxThreadSummary] = [] |
| 33 | private(set) var isLoading = false | 33 | private(set) var isLoading = false |
| 34 | var error: String? | 34 | var error: String? |
| 35 | var searchText = "" | ||
| 35 | 36 | ||
| 36 | private let mailingList: InboxMailingListReference | 37 | private let mailingList: InboxMailingListReference |
| 37 | private let client: SRHTClient | 38 | private let client: SRHTClient |
| @@ -61,6 +62,10 @@ final class MailingListDetailViewModel { | |||
| 61 | self.client = client | 62 | self.client = client |
| 62 | } | 63 | } |
| 63 | 64 | ||
| 65 | var filteredThreads: [InboxThreadSummary] { | ||
| 66 | Self.filterThreads(threads, matching: searchText) | ||
| 67 | } | ||
| 68 | |||
| 64 | func loadThreads() async { | 69 | func loadThreads() async { |
| 65 | guard !isLoading else { return } | 70 | guard !isLoading else { return } |
| 66 | isLoading = true | 71 | isLoading = true |
| @@ -187,6 +192,30 @@ final class MailingListDetailViewModel { | |||
| 187 | return lhs.lastActivityAt > rhs.lastActivityAt | 192 | return lhs.lastActivityAt > rhs.lastActivityAt |
| 188 | } | 193 | } |
| 189 | } | 194 | } |
| 195 | |||
| 196 | nonisolated static func filterThreads( | ||
| 197 | _ threads: [InboxThreadSummary], | ||
| 198 | matching query: String | ||
| 199 | ) -> [InboxThreadSummary] { | ||
| 200 | let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 201 | guard !q.isEmpty else { return threads } | ||
| 202 | return threads.filter { | ||
| 203 | normalizedSubject(from: $0.subject).contains(q) || | ||
| 204 | $0.latestSender.canonicalName.lowercased().contains(q) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | private nonisolated static func normalizedSubject(from subject: String) -> String { | ||
| 209 | subject | ||
| 210 | .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) | ||
| 211 | .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 212 | .replacingOccurrences( | ||
| 213 | of: #"^(?:(?:re|fwd?)\s*:\s*)+"#, | ||
| 214 | with: "", | ||
| 215 | options: [.regularExpression, .caseInsensitive] | ||
| 216 | ) | ||
| 217 | .lowercased() | ||
| 218 | } | ||
| 190 | } | 219 | } |
| 191 | 220 | ||
| 192 | struct MailingListDetailView: View { | 221 | struct MailingListDetailView: View { |
| @@ -225,7 +254,7 @@ struct MailingListDetailView: View { | |||
| 225 | @Bindable var vm = viewModel | 254 | @Bindable var vm = viewModel |
| 226 | 255 | ||
| 227 | List { | 256 | List { |
| 228 | ForEach(viewModel.threads) { thread in | 257 | ForEach(viewModel.filteredThreads) { thread in |
| 229 | NavigationLink(value: MoreRoute.thread(thread)) { | 258 | NavigationLink(value: MoreRoute.thread(thread)) { |
| 230 | InboxThreadRow(thread: thread) | 259 | InboxThreadRow(thread: thread) |
| 231 | } | 260 | } |
| @@ -249,6 +278,11 @@ struct MailingListDetailView: View { | |||
| 249 | } | 278 | } |
| 250 | } | 279 | } |
| 251 | .listStyle(.plain) | 280 | .listStyle(.plain) |
| 281 | .searchable( | ||
| 282 | text: $vm.searchText, | ||
| 283 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 284 | prompt: "Search messages" | ||
| 285 | ) | ||
| 252 | .overlay { | 286 | .overlay { |
| 253 | if viewModel.isLoading, viewModel.threads.isEmpty { | 287 | if viewModel.isLoading, viewModel.threads.isEmpty { |
| 254 | SRHTLoadingStateView(message: "Loading mailing list…") | 288 | SRHTLoadingStateView(message: "Loading mailing list…") |
| @@ -258,6 +292,8 @@ struct MailingListDetailView: View { | |||
| 258 | message: error, | 292 | message: error, |
| 259 | retryAction: { await viewModel.loadThreads() } | 293 | retryAction: { await viewModel.loadThreads() } |
| 260 | ) | 294 | ) |
| 295 | } else if !viewModel.threads.isEmpty, viewModel.filteredThreads.isEmpty { | ||
| 296 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 261 | } else if viewModel.threads.isEmpty { | 297 | } else if viewModel.threads.isEmpty { |
| 262 | ContentUnavailableView( | 298 | ContentUnavailableView( |
| 263 | "No Threads", | 299 | "No Threads", |
Hutch/Views/Tickets/TicketListView.swift +8
| @@ -108,6 +108,11 @@ struct TicketListView: View { | |||
| 108 | } | 108 | } |
| 109 | } | 109 | } |
| 110 | .listStyle(.plain) | 110 | .listStyle(.plain) |
| 111 | .searchable( | ||
| 112 | text: $vm.searchText, | ||
| 113 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 114 | prompt: "Search tickets" | ||
| 115 | ) | ||
| 111 | .overlay { | 116 | .overlay { |
| 112 | if viewModel.isLoading, viewModel.tickets.isEmpty { | 117 | if viewModel.isLoading, viewModel.tickets.isEmpty { |
| 113 | SRHTLoadingStateView(message: "Loading tickets…") | 118 | SRHTLoadingStateView(message: "Loading tickets…") |
| @@ -117,6 +122,9 @@ struct TicketListView: View { | |||
| 117 | message: error, | 122 | message: error, |
| 118 | retryAction: { await viewModel.loadTickets() } | 123 | retryAction: { await viewModel.loadTickets() } |
| 119 | ) | 124 | ) |
| 125 | } else if !viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, | ||
| 126 | viewModel.filteredTickets.isEmpty { | ||
| 127 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 120 | } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil { | 128 | } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil { |
| 121 | ContentUnavailableView( | 129 | ContentUnavailableView( |
| 122 | "No Tickets", | 130 | "No Tickets", |
Hutch/Views/Tickets/TicketListViewModel.swift +75 −5
| @@ -35,23 +35,40 @@ final class TicketListViewModel { | |||
| 35 | let ownerUsername: String | 35 | let ownerUsername: String |
| 36 | let trackerName: String | 36 | let trackerName: String |
| 37 | let trackerId: Int | 37 | let trackerId: Int |
| 38 | let trackerRid: String | ||
| 38 | 39 | ||
| 39 | private(set) var tickets: [TicketSummary] = [] | 40 | private(set) var tickets: [TicketSummary] = [] |
| 40 | private(set) var isLoading = false | 41 | private(set) var isLoading = false |
| 41 | private(set) var isLoadingMore = false | 42 | private(set) var isLoadingMore = false |
| 42 | private(set) var isCreatingTicket = false | 43 | private(set) var isCreatingTicket = false |
| 44 | private(set) var isPerformingAction = false | ||
| 45 | private(set) var trackerLabels: [TicketLabel] = [] | ||
| 43 | var error: String? | 46 | var error: String? |
| 44 | var filter: TicketFilter = .open | 47 | var filter: TicketFilter = .open { |
| 48 | didSet { | ||
| 49 | UserDefaults.standard.set(filter.rawValue, forKey: filterDefaultsKey) | ||
| 50 | } | ||
| 51 | } | ||
| 52 | var searchText = "" | ||
| 45 | 53 | ||
| 46 | private var cursor: String? | 54 | private var cursor: String? |
| 47 | private var hasMore = true | 55 | private var hasMore = true |
| 48 | private let client: SRHTClient | 56 | private let client: SRHTClient |
| 49 | 57 | ||
| 50 | init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) { | 58 | private var filterDefaultsKey: String { |
| 59 | "ticketFilter_\(trackerRid)" | ||
| 60 | } | ||
| 61 | |||
| 62 | init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, client: SRHTClient) { | ||
| 51 | self.ownerUsername = ownerUsername | 63 | self.ownerUsername = ownerUsername |
| 52 | self.trackerName = trackerName | 64 | self.trackerName = trackerName |
| 53 | self.trackerId = trackerId | 65 | self.trackerId = trackerId |
| 66 | self.trackerRid = trackerRid | ||
| 54 | self.client = client | 67 | self.client = client |
| 68 | if let raw = UserDefaults.standard.string(forKey: filterDefaultsKey), | ||
| 69 | let restored = TicketFilter(rawValue: raw) { | ||
| 70 | self.filter = restored | ||
| 71 | } | ||
| 55 | } | 72 | } |
| 56 | 73 | ||
| 57 | // MARK: - Query | 74 | // MARK: - Query |
| @@ -93,17 +110,70 @@ final class TicketListViewModel { | |||
| 93 | } | 110 | } |
| 94 | """ | 111 | """ |
| 95 | 112 | ||
| 113 | private static let updateStatusMutation = """ | ||
| 114 | mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) { | ||
| 115 | updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) { | ||
| 116 | eventType: __typename | ||
| 117 | } | ||
| 118 | } | ||
| 119 | """ | ||
| 120 | |||
| 121 | private static let assignUserMutation = """ | ||
| 122 | mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { | ||
| 123 | assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } | ||
| 124 | } | ||
| 125 | """ | ||
| 126 | |||
| 127 | private static let unassignUserMutation = """ | ||
| 128 | mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { | ||
| 129 | unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } | ||
| 130 | } | ||
| 131 | """ | ||
| 132 | |||
| 133 | private static let labelTicketMutation = """ | ||
| 134 | mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { | ||
| 135 | labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } | ||
| 136 | } | ||
| 137 | """ | ||
| 138 | |||
| 139 | private static let unlabelTicketMutation = """ | ||
| 140 | mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { | ||
| 141 | unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } | ||
| 142 | } | ||
| 143 | """ | ||
| 144 | |||
| 145 | private static let trackerLabelsQuery = """ | ||
| 146 | query trackerLabels($owner: String!, $tracker: String!) { | ||
| 147 | user(username: $owner) { | ||
| 148 | tracker(name: $tracker) { | ||
| 149 | labels { | ||
| 150 | results { id name backgroundColor foregroundColor } | ||
| 151 | } | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
| 155 | """ | ||
| 156 | |||
| 96 | // MARK: - Computed | 157 | // MARK: - Computed |
| 97 | 158 | ||
| 98 | /// Tickets filtered by the selected status filter. | 159 | /// Tickets filtered by the selected status filter. |
| 99 | var filteredTickets: [TicketSummary] { | 160 | var filteredTickets: [TicketSummary] { |
| 161 | let statusFiltered: [TicketSummary] | ||
| 100 | switch filter { | 162 | switch filter { |
| 101 | case .open: | 163 | case .open: |
| 102 | tickets.filter { $0.status.isOpen } | 164 | statusFiltered = tickets.filter { $0.status.isOpen } |
| 103 | case .resolved: | 165 | case .resolved: |
| 104 | tickets.filter { !$0.status.isOpen } | 166 | statusFiltered = tickets.filter { !$0.status.isOpen } |
| 105 | case .all: | 167 | case .all: |
| 106 | tickets | 168 | statusFiltered = tickets |
| 169 | } | ||
| 170 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 171 | guard !q.isEmpty else { return statusFiltered } | ||
| 172 | return statusFiltered.filter { | ||
| 173 | String($0.id).contains(q) || | ||
| 174 | $0.title.lowercased().contains(q) || | ||
| 175 | $0.submitter.canonicalName.lowercased().contains(q) || | ||
| 176 | $0.labels.contains { $0.name.lowercased().contains(q) } | ||
| 107 | } | 177 | } |
| 108 | } | 178 | } |
| 109 | 179 | ||
Hutch/Views/Tickets/TrackerListView.swift +8 −1
| @@ -65,7 +65,7 @@ struct TrackerListView: View { | |||
| 65 | @Bindable var vm = viewModel | 65 | @Bindable var vm = viewModel |
| 66 | 66 | ||
| 67 | List { | 67 | List { |
| 68 | ForEach(viewModel.trackers) { tracker in | 68 | ForEach(viewModel.filteredTrackers) { tracker in |
| 69 | NavigationLink(value: tracker) { | 69 | NavigationLink(value: tracker) { |
| 70 | TrackerRowView(tracker: tracker) | 70 | TrackerRowView(tracker: tracker) |
| 71 | } | 71 | } |
| @@ -84,6 +84,11 @@ struct TrackerListView: View { | |||
| 84 | } | 84 | } |
| 85 | } | 85 | } |
| 86 | .listStyle(.plain) | 86 | .listStyle(.plain) |
| 87 | .searchable( | ||
| 88 | text: $vm.searchText, | ||
| 89 | placement: .navigationBarDrawer(displayMode: .always), | ||
| 90 | prompt: "Search trackers" | ||
| 91 | ) | ||
| 87 | .overlay { | 92 | .overlay { |
| 88 | if viewModel.isLoading, viewModel.trackers.isEmpty { | 93 | if viewModel.isLoading, viewModel.trackers.isEmpty { |
| 89 | SRHTLoadingStateView(message: "Loading trackers…") | 94 | SRHTLoadingStateView(message: "Loading trackers…") |
| @@ -93,6 +98,8 @@ struct TrackerListView: View { | |||
| 93 | message: error, | 98 | message: error, |
| 94 | retryAction: { await viewModel.loadTrackers() } | 99 | retryAction: { await viewModel.loadTrackers() } |
| 95 | ) | 100 | ) |
| 101 | } else if !viewModel.trackers.isEmpty, viewModel.filteredTrackers.isEmpty { | ||
| 102 | ContentUnavailableView.search(text: viewModel.searchText) | ||
| 96 | } else if viewModel.trackers.isEmpty, viewModel.error == nil { | 103 | } else if viewModel.trackers.isEmpty, viewModel.error == nil { |
| 97 | ContentUnavailableView( | 104 | ContentUnavailableView( |
| 98 | "No Trackers", | 105 | "No Trackers", |
Hutch/Views/Tickets/TrackerListViewModel.swift +11
| @@ -22,6 +22,7 @@ final class TrackerListViewModel { | |||
| 22 | private(set) var isLoadingMore = false | 22 | private(set) var isLoadingMore = false |
| 23 | private(set) var isCreatingTracker = false | 23 | private(set) var isCreatingTracker = false |
| 24 | var error: String? | 24 | var error: String? |
| 25 | var searchText = "" | ||
| 25 | 26 | ||
| 26 | private var cursor: String? | 27 | private var cursor: String? |
| 27 | private var hasMore = true | 28 | private var hasMore = true |
| @@ -31,6 +32,16 @@ final class TrackerListViewModel { | |||
| 31 | self.client = client | 32 | self.client = client |
| 32 | } | 33 | } |
| 33 | 34 | ||
| 35 | var filteredTrackers: [TrackerSummary] { | ||
| 36 | let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 37 | guard !q.isEmpty else { return trackers } | ||
| 38 | return trackers.filter { | ||
| 39 | $0.name.lowercased().contains(q) || | ||
| 40 | ($0.description?.lowercased().contains(q) == true) || | ||
| 41 | $0.owner.canonicalName.lowercased().contains(q) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 34 | // MARK: - Query | 45 | // MARK: - Query |
| 35 | 46 | ||
| 36 | private static let query = """ | 47 | private static let query = """ |
HutchTests/BuildListViewModelTests.swift added +55
| @@ -0,0 +1,55 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import Hutch | ||
| 4 | |||
| 5 | struct BuildListViewModelTests { | ||
| 6 | |||
| 7 | @Test | ||
| 8 | func filteredJobsReturnsAllWhenSearchTextIsEmpty() { | ||
| 9 | let jobs = [makeJob(id: 1, tags: ["ci"]), makeJob(id: 2, tags: [])] | ||
| 10 | let filtered = filterJobs(jobs, query: "") | ||
| 11 | |||
| 12 | #expect(filtered.count == 2) | ||
| 13 | } | ||
| 14 | |||
| 15 | @Test | ||
| 16 | func filteredJobsMatchesByTag() { | ||
| 17 | let jobs = [makeJob(id: 1, tags: ["ci", "deploy"]), makeJob(id: 2, tags: ["lint"])] | ||
| 18 | let filtered = filterJobs(jobs, query: "deploy") | ||
| 19 | |||
| 20 | #expect(filtered.map(\.id) == [1]) | ||
| 21 | } | ||
| 22 | |||
| 23 | @Test | ||
| 24 | func filteredJobsMatchesByJobId() { | ||
| 25 | let jobs = [makeJob(id: 42, tags: []), makeJob(id: 99, tags: [])] | ||
| 26 | let filtered = filterJobs(jobs, query: "42") | ||
| 27 | |||
| 28 | #expect(filtered.map(\.id) == [42]) | ||
| 29 | } | ||
| 30 | |||
| 31 | private func filterJobs(_ jobs: [JobSummary], query: String) -> [JobSummary] { | ||
| 32 | let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 33 | guard !q.isEmpty else { return jobs } | ||
| 34 | return jobs.filter { | ||
| 35 | String($0.id).contains(q) || | ||
| 36 | $0.tags.contains { $0.lowercased().contains(q) } || | ||
| 37 | ($0.note?.lowercased().contains(q) == true) || | ||
| 38 | ($0.image?.lowercased().contains(q) == true) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | private func makeJob(id: Int, tags: [String]) -> JobSummary { | ||
| 43 | JobSummary( | ||
| 44 | id: id, | ||
| 45 | created: Date(), | ||
| 46 | updated: Date(), | ||
| 47 | status: .success, | ||
| 48 | note: nil, | ||
| 49 | tags: tags, | ||
| 50 | visibility: nil, | ||
| 51 | image: nil, | ||
| 52 | tasks: [] | ||
| 53 | ) | ||
| 54 | } | ||
| 55 | } | ||
HutchTests/InboxViewModelTests.swift +52
| @@ -113,6 +113,58 @@ struct InboxViewModelTests { | |||
| 113 | #expect(first.id != second.id) | 113 | #expect(first.id != second.id) |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | @Test | ||
| 117 | func mailingListThreadFilterMatchesSubjectAndSender() { | ||
| 118 | let baseList = InboxMailingListReference( | ||
| 119 | id: 1, | ||
| 120 | rid: "list", | ||
| 121 | name: "hutch-devel", | ||
| 122 | owner: Entity(canonicalName: "~owner") | ||
| 123 | ) | ||
| 124 | let threads = [ | ||
| 125 | InboxThreadSummary( | ||
| 126 | rootEmailID: 10, | ||
| 127 | rootMessageID: "message-1", | ||
| 128 | threadRootEmailIDs: [10], | ||
| 129 | threadRootMessageIDs: ["message-1"], | ||
| 130 | listID: baseList.id, | ||
| 131 | listRID: baseList.rid, | ||
| 132 | listName: baseList.name, | ||
| 133 | listOwner: baseList.owner, | ||
| 134 | subject: "Re: [PATCH] add search", | ||
| 135 | latestSender: Entity(canonicalName: "~alice"), | ||
| 136 | lastActivityAt: Date(timeIntervalSince1970: 100), | ||
| 137 | messageCount: 1, | ||
| 138 | repo: "hutch", | ||
| 139 | containsPatch: true, | ||
| 140 | isUnread: true | ||
| 141 | ), | ||
| 142 | InboxThreadSummary( | ||
| 143 | rootEmailID: 11, | ||
| 144 | rootMessageID: "message-2", | ||
| 145 | threadRootEmailIDs: [11], | ||
| 146 | threadRootMessageIDs: ["message-2"], | ||
| 147 | listID: baseList.id, | ||
| 148 | listRID: baseList.rid, | ||
| 149 | listName: baseList.name, | ||
| 150 | listOwner: baseList.owner, | ||
| 151 | subject: "Release planning", | ||
| 152 | latestSender: Entity(canonicalName: "~bob"), | ||
| 153 | lastActivityAt: Date(timeIntervalSince1970: 200), | ||
| 154 | messageCount: 2, | ||
| 155 | repo: "hutch", | ||
| 156 | containsPatch: false, | ||
| 157 | isUnread: true | ||
| 158 | ) | ||
| 159 | ] | ||
| 160 | |||
| 161 | let subjectMatches = MailingListDetailViewModel.filterThreads(threads, matching: "search") | ||
| 162 | let senderMatches = MailingListDetailViewModel.filterThreads(threads, matching: "~bob") | ||
| 163 | |||
| 164 | #expect(subjectMatches.map(\.rootEmailID) == [10]) | ||
| 165 | #expect(senderMatches.map(\.rootEmailID) == [11]) | ||
| 166 | } | ||
| 167 | |||
| 116 | @Test | 168 | @Test |
| 117 | func segmentsPatchBodyAndTreatsSignatureAsPlainText() { | 169 | func segmentsPatchBodyAndTreatsSignatureAsPlainText() { |
| 118 | let body = """ | 170 | let body = """ |
HutchTests/TrackerListViewModelTests.swift +58
| @@ -13,4 +13,62 @@ struct TrackerListViewModelTests { | |||
| 13 | 13 | ||
| 14 | #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists") | 14 | #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists") |
| 15 | } | 15 | } |
| 16 | |||
| 17 | @Test | ||
| 18 | func filteredTrackersReturnsAllWhenSearchTextIsEmpty() { | ||
| 19 | let trackers = [ | ||
| 20 | makeTracker(id: 1, name: "bugs", description: "Bug tracker", owner: "~owner"), | ||
| 21 | makeTracker(id: 2, name: "ideas", description: nil, owner: "~team") | ||
| 22 | ] | ||
| 23 | |||
| 24 | let filtered = filterTrackers(trackers, query: "") | ||
| 25 | |||
| 26 | #expect(filtered.count == 2) | ||
| 27 | } | ||
| 28 | |||
| 29 | @Test | ||
| 30 | func filteredTrackersMatchesDescription() { | ||
| 31 | let trackers = [ | ||
| 32 | makeTracker(id: 1, name: "bugs", description: "Production incidents", owner: "~owner"), | ||
| 33 | makeTracker(id: 2, name: "ideas", description: "Feature requests", owner: "~team") | ||
| 34 | ] | ||
| 35 | |||
| 36 | let filtered = filterTrackers(trackers, query: "incident") | ||
| 37 | |||
| 38 | #expect(filtered.map(\.id) == [1]) | ||
| 39 | } | ||
| 40 | |||
| 41 | @Test | ||
| 42 | func filteredTrackersMatchesOwner() { | ||
| 43 | let trackers = [ | ||
| 44 | makeTracker(id: 1, name: "bugs", description: nil, owner: "~owner"), | ||
| 45 | makeTracker(id: 2, name: "ideas", description: nil, owner: "~team") | ||
| 46 | ] | ||
| 47 | |||
| 48 | let filtered = filterTrackers(trackers, query: "~team") | ||
| 49 | |||
| 50 | #expect(filtered.map(\.id) == [2]) | ||
| 51 | } | ||
| 52 | |||
| 53 | private func filterTrackers(_ trackers: [TrackerSummary], query: String) -> [TrackerSummary] { | ||
| 54 | let q = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| 55 | guard !q.isEmpty else { return trackers } | ||
| 56 | return trackers.filter { | ||
| 57 | $0.name.lowercased().contains(q) || | ||
| 58 | ($0.description?.lowercased().contains(q) == true) || | ||
| 59 | $0.owner.canonicalName.lowercased().contains(q) | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | private func makeTracker(id: Int, name: String, description: String?, owner: String) -> TrackerSummary { | ||
| 64 | TrackerSummary( | ||
| 65 | id: id, | ||
| 66 | rid: "rid-\(id)", | ||
| 67 | name: name, | ||
| 68 | description: description, | ||
| 69 | visibility: .public, | ||
| 70 | updated: Date(), | ||
| 71 | owner: Entity(canonicalName: owner) | ||
| 72 | ) | ||
| 73 | } | ||
| 16 | } | 74 | } |