gitbay/Views/Builds/BuildListView.swift

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

190 lines · 6718 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            PageFooter(list: model.list)
 25        }
 26        .overlay { LoadStateOverlay(state: model.state) }
 27        .navigationTitle("Builds")
 28        .navigationBarTitleDisplayMode(.inline)
 29        .toolbar {
 30            ToolbarItem(placement: .topBarTrailing) {
 31                Button {
 32                    filtering = true
 33                } label: {
 34                    Image(systemName: model.filter.isNarrowed
 35                        ? "line.3.horizontal.decrease.circle.fill"
 36                        : "line.3.horizontal.decrease.circle")
 37                }
 38                .accessibilityIdentifier("build-filter-button")
 39            }
 40            ToolbarItem(placement: .topBarTrailing) {
 41                // A menu of jobs, like the web's picker — not a name to
 42                // type. Nothing to trigger without a job file.
 43                Menu {
 44                    ForEach(model.jobs) { job in
 45                        Button("\(job.name) — \(job.trigger)") {
 46                            Task { await model.trigger(job: job.name) }
 47                        }
 48                    }
 49                } label: {
 50                    Image(systemName: "play.circle")
 51                }
 52                .disabled(model.working || model.jobs.isEmpty)
 53                .accessibilityIdentifier("build-trigger-button")
 54            }
 55        }
 56        .sheet(isPresented: $filtering) {
 57            BuildFilterSheet(filter: model.filter, jobs: model.jobs) { model.filter = $0 }
 58        }
 59        .task {
 60            await model.loadIfNeeded()
 61            await model.loadJobs()
 62        }
 63        .refreshable {
 64            await model.load()
 65            await model.loadJobs()
 66        }
 67    }
 68}
 69
 70private struct BuildRow: View {
 71    let build: Build
 72
 73    var body: some View {
 74        HStack(spacing: 10) {
 75            Image(systemName: build.statusIcon)
 76                .foregroundStyle(build.statusColor)
 77            VStack(alignment: .leading, spacing: 2) {
 78                Text(build.title)
 79                    .font(.gbSans(.subheadline).weight(.medium))
 80                    .lineLimit(2)
 81                HStack(spacing: 6) {
 82                    Text(build.job)
 83                    Text(build.ref)
 84                        .truncationMode(.middle)
 85                    Text(build.shortSHA)
 86                        .font(.gbMono(.caption))
 87                        .fixedSize()
 88                }
 89                .lineLimit(1)
 90                .font(.gbSans(.caption))
 91                .foregroundStyle(.secondary)
 92            }
 93            Spacer()
 94            VStack(alignment: .trailing, spacing: 2) {
 95                Text(build.status)
 96                    .font(.gbSans(.caption).weight(.medium))
 97                    .foregroundStyle(build.statusColor)
 98                Text(build.createdAt, format: .relative(presentation: .named))
 99                    .font(.gbSans(.caption))
100                    .foregroundStyle(.secondary)
101            }
102        }
103        .padding(.vertical, 2)
104    }
105
106}
107
108/// Branch, status and job, the three flags `build list` takes. The
109/// sheet edits a copy and hands it back on Done, so the list reloads
110/// once rather than on every keystroke of the branch field.
111private struct BuildFilterSheet: View {
112
113    let jobs: [CIJob]
114    let onApply: (BuildFilter) -> Void
115    @State private var draft: BuildFilter
116
117    @Environment(\.dismiss) private var dismiss
118
119    init(filter: BuildFilter, jobs: [CIJob], onApply: @escaping (BuildFilter) -> Void) {
120        _draft = State(initialValue: filter)
121        self.jobs = jobs
122        self.onApply = onApply
123    }
124
125    var body: some View {
126        NavigationStack {
127            Form {
128                Section("Branch") {
129                    TextField("Branch", text: $draft.ref)
130                        .autocorrectionDisabled()
131                        .textInputAutocapitalization(.never)
132                        .accessibilityIdentifier("build-filter-ref")
133                }
134                Section("Status") {
135                    Menu {
136                        Button("Any status") { draft.status = nil }
137                        ForEach(BuildFilter.Status.allCases) { status in
138                            Button(status.rawValue) { draft.status = status }
139                        }
140                    } label: {
141                        menuLabel(draft.status?.rawValue ?? "Any status")
142                    }
143                    .accessibilityIdentifier("build-filter-status-menu")
144                }
145                // A repository without a CI config has no jobs to name.
146                if !jobs.isEmpty {
147                    Section("Job") {
148                        Menu {
149                            Button("Any job") { draft.job = nil }
150                            ForEach(jobs) { job in
151                                Button(job.name) { draft.job = job.name }
152                            }
153                        } label: {
154                            menuLabel(draft.job ?? "Any job")
155                        }
156                        .accessibilityIdentifier("build-filter-job-menu")
157                    }
158                }
159                Section {
160                    Button("Clear filters", role: .destructive) { draft = BuildFilter() }
161                }
162            }
163            .navigationTitle("Filter Builds")
164            .navigationBarTitleDisplayMode(.inline)
165            .toolbar {
166                ToolbarItem(placement: .confirmationAction) {
167                    Button("Done") {
168                        onApply(draft)
169                        dismiss()
170                    }
171                }
172            }
173        }
174    }
175
176    private func menuLabel(_ text: String) -> some View {
177        HStack {
178            Text(text)
179            Spacer()
180            Image(systemName: "chevron.up.chevron.down")
181                .font(.gbSans(.caption2))
182                .foregroundStyle(.secondary)
183        }
184    }
185}
186
187nonisolated enum BuildRoute: Hashable {
188    case list(repo: String)
189    case detail(repo: String, number: Int64)
190}