Commit d0d608bdb6
Unsigned
Layout: unified · split
gitbay/Builds/BuildFilter.swift added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// What `build list` is narrowed to: `--ref`, `--status` and `--job`. | |
| 4 | /// There is no state flag, so the default sends the bare command. | |
| 5 | nonisolated struct BuildFilter: Equatable, Sendable { | |
| 6 | ||
| 7 | /// The vocabulary `--status` accepts; anything else is a usage error. | |
| 8 | enum Status: String, CaseIterable, Identifiable, Sendable { | |
| 9 | case pending, running, success, failure, cancelled | |
| 10 | ||
| 11 | var id: String { rawValue } | |
| 12 | } | |
| 13 | ||
| 14 | var ref = "" | |
| 15 | var status: Status? | |
| 16 | var job: String? | |
| 17 | ||
| 18 | func flags() -> [String] { | |
| 19 | var flags = filterFlag("--ref", ref) | |
| 20 | flags += filterFlag("--status", status?.rawValue) | |
| 21 | flags += filterFlag("--job", job) | |
| 22 | return flags | |
| 23 | } | |
| 24 | ||
| 25 | var isNarrowed: Bool { !flags().isEmpty } | |
| 26 | ||
| 27 | var emptyMessage: String { | |
| 28 | isNarrowed | |
| 29 | ? "No builds match these filters." | |
| 30 | : "No builds. Builds run when a push touches a repo with a .gitbay/ job file." | |
| 31 | } | |
| 32 | } | |
gitbay/Builds/BuildListViewModel.swift +14 −3
| @@ -1,12 +1,22 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 4 | /// `build list <repo>`. | |
| 4 | /// `build list <repo>`, narrowed by `filter`. | |
| 5 | 5 | @Observable |
| 6 | 6 | @MainActor |
| 7 | 7 | final class BuildListViewModel { |
| 8 | 8 | |
| 9 | 9 | private(set) var state: LoadState<[Build]> = .loading |
| 10 | /// A change reloads. The sheet edits a copy and assigns once, so a | |
| 11 | /// typed branch does not fire a request per keystroke. | |
| 12 | var filter = BuildFilter() { | |
| 13 | didSet { | |
| 14 | guard filter != oldValue else { return } | |
| 15 | reloadTask?.cancel() | |
| 16 | reloadTask = Task { await load() } | |
| 17 | } | |
| 18 | } | |
| 19 | private var reloadTask: Task<Void, Never>? | |
| 10 | 20 | /// The jobs a trigger can name. Empty when the repo has no CI |
| 11 | 21 | /// config — the web hides its trigger form in that case. |
| 12 | 22 | private(set) var jobs: [CIJob] = [] |
| @@ -23,9 +33,10 @@ final class BuildListViewModel { | ||
| 23 | 33 | |
| 24 | 34 | func load() async { |
| 25 | 35 | do { |
| 26 | let builds = try await client.readList(["build", "list", repoPath], of: Build.self) | |
| 36 | let builds = try await client.readList( | |
| 37 | ["build", "list", repoPath] + filter.flags(), of: Build.self) | |
| 27 | 38 | state = builds.isEmpty |
| 28 | ? .empty("No builds. Builds run when a push touches a repo with a .gitbay/ job file.") | |
| 39 | ? .empty(filter.emptyMessage) | |
| 29 | 40 | : .loaded(builds.sorted { $0.number > $1.number }) |
| 30 | 41 | } catch { |
| 31 | 42 | state = .from(error) |
gitbay/Views/Builds/BuildListView.swift +93
| @@ -3,6 +3,7 @@ import SwiftUI | ||
| 3 | 3 | struct BuildListView: View { |
| 4 | 4 | |
| 5 | 5 | @State private var model: BuildListViewModel |
| 6 | @State private var filtering = false | |
| 6 | 7 | |
| 7 | 8 | init(client: GitbayClient, repo: String) { |
| 8 | 9 | _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) |
| @@ -25,6 +26,16 @@ struct BuildListView: View { | ||
| 25 | 26 | .navigationTitle("Builds") |
| 26 | 27 | .navigationBarTitleDisplayMode(.inline) |
| 27 | 28 | .toolbar { |
| 29 | ToolbarItem(placement: .topBarTrailing) { | |
| 30 | Button { | |
| 31 | filtering = true | |
| 32 | } label: { | |
| 33 | Image(systemName: model.filter.isNarrowed | |
| 34 | ? "line.3.horizontal.decrease.circle.fill" | |
| 35 | : "line.3.horizontal.decrease.circle") | |
| 36 | } | |
| 37 | .accessibilityIdentifier("build-filter-button") | |
| 38 | } | |
| 28 | 39 | ToolbarItem(placement: .topBarTrailing) { |
| 29 | 40 | // A menu of jobs, like the web's picker — not a name to |
| 30 | 41 | // type. Nothing to trigger without a job file. |
| @@ -41,6 +52,9 @@ struct BuildListView: View { | ||
| 41 | 52 | .accessibilityIdentifier("build-trigger-button") |
| 42 | 53 | } |
| 43 | 54 | } |
| 55 | .sheet(isPresented: $filtering) { | |
| 56 | BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 } | |
| 57 | } | |
| 44 | 58 | .task { |
| 45 | 59 | await model.load() |
| 46 | 60 | await model.loadJobs() |
| @@ -85,6 +99,85 @@ private struct BuildRow: View { | ||
| 85 | 99 | |
| 86 | 100 | } |
| 87 | 101 | |
| 102 | /// Branch, status and job, the three flags `build list` takes. The | |
| 103 | /// sheet edits a copy and hands it back on Done, so the list reloads | |
| 104 | /// once rather than on every keystroke of the branch field. | |
| 105 | private struct BuildFilterSheet: View { | |
| 106 | ||
| 107 | let jobs: [CIJob] | |
| 108 | let onApply: (BuildFilter) -> Void | |
| 109 | @State private var draft: BuildFilter | |
| 110 | ||
| 111 | @Environment(\.dismiss) private var dismiss | |
| 112 | ||
| 113 | init(filter: BuildFilter, jobs: [CIJob], onApply: @escaping (BuildFilter) -> Void) { | |
| 114 | _draft = State(initialValue: filter) | |
| 115 | self.jobs = jobs | |
| 116 | self.onApply = onApply | |
| 117 | } | |
| 118 | ||
| 119 | var body: some View { | |
| 120 | NavigationStack { | |
| 121 | Form { | |
| 122 | Section("Branch") { | |
| 123 | TextField("Branch", text: $draft.ref) | |
| 124 | .autocorrectionDisabled() | |
| 125 | .textInputAutocapitalization(.never) | |
| 126 | .accessibilityIdentifier("build-filter-ref") | |
| 127 | } | |
| 128 | Section("Status") { | |
| 129 | Menu { | |
| 130 | Button("Any status") { draft.status = nil } | |
| 131 | ForEach(BuildFilter.Status.allCases) { status in | |
| 132 | Button(status.rawValue) { draft.status = status } | |
| 133 | } | |
| 134 | } label: { | |
| 135 | menuLabel(draft.status?.rawValue ?? "Any status") | |
| 136 | } | |
| 137 | .accessibilityIdentifier("build-filter-status-menu") | |
| 138 | } | |
| 139 | // A repository without a CI config has no jobs to name. | |
| 140 | if !jobs.isEmpty { | |
| 141 | Section("Job") { | |
| 142 | Menu { | |
| 143 | Button("Any job") { draft.job = nil } | |
| 144 | ForEach(jobs) { job in | |
| 145 | Button(job.name) { draft.job = job.name } | |
| 146 | } | |
| 147 | } label: { | |
| 148 | menuLabel(draft.job ?? "Any job") | |
| 149 | } | |
| 150 | .accessibilityIdentifier("build-filter-job-menu") | |
| 151 | } | |
| 152 | } | |
| 153 | Section { | |
| 154 | Button("Clear filters", role: .destructive) { draft = BuildFilter() } | |
| 155 | } | |
| 156 | } | |
| 157 | .navigationTitle("Filter Builds") | |
| 158 | .navigationBarTitleDisplayMode(.inline) | |
| 159 | .toolbar { | |
| 160 | ToolbarItem(placement: .confirmationAction) { | |
| 161 | Button("Done") { | |
| 162 | onApply(draft) | |
| 163 | dismiss() | |
| 164 | } | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | } | |
| 169 | ||
| 170 | private func menuLabel(_ text: String) -> some View { | |
| 171 | HStack { | |
| 172 | Text(text) | |
| 173 | Spacer() | |
| 174 | Image(systemName: "chevron.up.chevron.down") | |
| 175 | .font(.gbSans(.caption2)) | |
| 176 | .foregroundStyle(.secondary) | |
| 177 | } | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 88 | 181 | nonisolated enum BuildRoute: Hashable { |
| 89 | 182 | case list(repo: String) |
| 90 | 183 | case detail(repo: String, number: Int64) |
gitbayTests/IssueBuildViewModelTests.swift +31
| @@ -159,6 +159,37 @@ struct BuildListViewModelTests { | ||
| 159 | 159 | #expect(builds[0].finishedAt == nil) |
| 160 | 160 | #expect(builds[1].finishedAt != nil) |
| 161 | 161 | } |
| 162 | ||
| 163 | @Test func changingTheFilterReloadsWithItsFlags() async throws { | |
| 164 | let (client, stub) = try makeClient() | |
| 165 | stub.enqueue(.init(status: 200, json: buildListJSON)) | |
| 166 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | |
| 167 | await model.load() | |
| 168 | ||
| 169 | stub.enqueue(.init(status: 200, json: """ | |
| 170 | {"protocol_version":1,"data":[],"exit_code":0} | |
| 171 | """)) | |
| 172 | var filter = BuildFilter() | |
| 173 | filter.status = .failure | |
| 174 | filter.job = "test" | |
| 175 | model.filter = filter | |
| 176 | // `load()` leaves the previous list showing until the reply lands | |
| 177 | // rather than blanking it, so a "not loading" check would pass | |
| 178 | // before the reload finished: wait for the result itself. | |
| 179 | await until { | |
| 180 | if case .empty = model.state { return true } | |
| 181 | return false | |
| 182 | } | |
| 183 | ||
| 184 | let second = try #require(stub.seen.dropFirst().first) | |
| 185 | #expect(second.url.query() | |
| 186 | == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test") | |
| 187 | guard case .empty(let message) = model.state else { | |
| 188 | Issue.record("expected .empty after filtering, got \(model.state)") | |
| 189 | return | |
| 190 | } | |
| 191 | #expect(message == "No builds match these filters.") | |
| 192 | } | |
| 162 | 193 | } |
| 163 | 194 | |
| 164 | 195 | @MainActor |
gitbayTests/ListFilterTests.swift +39
| @@ -402,3 +402,42 @@ struct MRListFilterWiringTests { | ||
| 402 | 402 | #expect(argvFrom(try #require(stub.seen.last).url).contains("source_gone")) |
| 403 | 403 | } |
| 404 | 404 | } |
| 405 | ||
| 406 | struct BuildFilterTests { | |
| 407 | ||
| 408 | /// `build list` has no state flag, so nothing is narrowed by default | |
| 409 | /// and the bare command is sent. | |
| 410 | @Test func theDefaultSendsNoFlags() { | |
| 411 | #expect(BuildFilter().flags().isEmpty) | |
| 412 | #expect(BuildFilter().isNarrowed == false) | |
| 413 | } | |
| 414 | ||
| 415 | @Test func eachFieldRendersItsOwnFlag() { | |
| 416 | var f = BuildFilter() | |
| 417 | f.ref = "main" | |
| 418 | f.status = .failure | |
| 419 | f.job = "test" | |
| 420 | #expect(f.flags() == ["--ref", "main", "--status", "failure", "--job", "test"]) | |
| 421 | #expect(f.isNarrowed) | |
| 422 | } | |
| 423 | ||
| 424 | @Test func aBlankBranchSendsNoFlag() { | |
| 425 | var f = BuildFilter() | |
| 426 | f.ref = " " | |
| 427 | #expect(f.flags().isEmpty) | |
| 428 | #expect(f.isNarrowed == false) | |
| 429 | } | |
| 430 | ||
| 431 | @Test func theStatusVocabularyIsTheServersFive() { | |
| 432 | #expect(BuildFilter.Status.allCases.map(\.rawValue) | |
| 433 | == ["pending", "running", "success", "failure", "cancelled"]) | |
| 434 | } | |
| 435 | ||
| 436 | @Test func theEmptyMessageSaysWhetherFiltersApply() { | |
| 437 | #expect(BuildFilter().emptyMessage | |
| 438 | == "No builds. Builds run when a push touches a repo with a .gitbay/ job file.") | |
| 439 | var f = BuildFilter() | |
| 440 | f.status = .success | |
| 441 | #expect(f.emptyMessage == "No builds match these filters.") | |
| 442 | } | |
| 443 | } | |