gitbay/Views/Builds/BuildListView.swift

v1.2.0
gitbay-ios/gitbay/Views/Builds/BuildListView.swift history · blame · raw

184 lines · 6503 bytes

  1import SwiftUI
  2
  3struct BuildListView: View {
  4
  5    @State private var model: BuildListViewModel
  6    @State private var filtering = false
  7
  8    init(client: GitbayClient, repo: String) {
  9        _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
 10    }
 11
 12    var body: some View {
 13        List {
 14            if let error = model.actionError {
 15                Section {
 16                    GBNotice(error, .gbWarn)
 17                }
 18            }
 19            ForEach(model.state.value ?? []) { build in
 20                NavigationLink(value: BuildRoute.detail(repo: model.repoPath, number: build.number)) {
 21                    BuildRow(build: build)
 22                }
 23            }
 24        }
 25        .overlay { LoadStateOverlay(state: model.state) }
 26        .navigationTitle("Builds")
 27        .navigationBarTitleDisplayMode(.inline)
 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            }
 39            ToolbarItem(placement: .topBarTrailing) {
 40                // A menu of jobs, like the web's picker — not a name to
 41                // type. Nothing to trigger without a job file.
 42                Menu {
 43                    ForEach(model.jobs) { job in
 44                        Button("\(job.name) — \(job.trigger)") {
 45                            Task { await model.trigger(job: job.name) }
 46                        }
 47                    }
 48                } label: {
 49                    Image(systemName: "play.circle")
 50                }
 51                .disabled(model.working || model.jobs.isEmpty)
 52                .accessibilityIdentifier("build-trigger-button")
 53            }
 54        }
 55        .sheet(isPresented: $filtering) {
 56            BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
 57        }
 58        .task {
 59            await model.load()
 60            await model.loadJobs()
 61        }
 62        .refreshable {
 63            await model.load()
 64            await model.loadJobs()
 65        }
 66    }
 67}
 68
 69private struct BuildRow: View {
 70    let build: Build
 71
 72    var body: some View {
 73        HStack(spacing: 10) {
 74            Image(systemName: build.statusIcon)
 75                .foregroundStyle(build.statusColor)
 76            VStack(alignment: .leading, spacing: 2) {
 77                Text("#\(build.number) \(build.job)")
 78                    .font(.gbSans(.subheadline).weight(.medium))
 79                HStack(spacing: 6) {
 80                    Text(build.ref)
 81                    Text(build.shortSHA)
 82                        .font(.gbMono(.caption))
 83                }
 84                .font(.gbSans(.caption))
 85                .foregroundStyle(.secondary)
 86            }
 87            Spacer()
 88            VStack(alignment: .trailing, spacing: 2) {
 89                Text(build.status)
 90                    .font(.gbSans(.caption).weight(.medium))
 91                    .foregroundStyle(build.statusColor)
 92                Text(build.createdAt, format: .relative(presentation: .named))
 93                    .font(.gbSans(.caption))
 94                    .foregroundStyle(.secondary)
 95            }
 96        }
 97        .padding(.vertical, 2)
 98    }
 99
100}
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.
105private 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
181nonisolated enum BuildRoute: Hashable {
182    case list(repo: String)
183    case detail(repo: String, number: Int64)
184}