gitbay/Views/Builds/BuildListView.swift

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

187 lines · 6602 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                    Text(build.shortSHA)
 85                        .font(.gbMono(.caption))
 86                }
 87                .font(.gbSans(.caption))
 88                .foregroundStyle(.secondary)
 89            }
 90            Spacer()
 91            VStack(alignment: .trailing, spacing: 2) {
 92                Text(build.status)
 93                    .font(.gbSans(.caption).weight(.medium))
 94                    .foregroundStyle(build.statusColor)
 95                Text(build.createdAt, format: .relative(presentation: .named))
 96                    .font(.gbSans(.caption))
 97                    .foregroundStyle(.secondary)
 98            }
 99        }
100        .padding(.vertical, 2)
101    }
102
103}
104
105/// Branch, status and job, the three flags `build list` takes. The
106/// sheet edits a copy and hands it back on Done, so the list reloads
107/// once rather than on every keystroke of the branch field.
108private struct BuildFilterSheet: View {
109
110    let jobs: [CIJob]
111    let onApply: (BuildFilter) -> Void
112    @State private var draft: BuildFilter
113
114    @Environment(\.dismiss) private var dismiss
115
116    init(filter: BuildFilter, jobs: [CIJob], onApply: @escaping (BuildFilter) -> Void) {
117        _draft = State(initialValue: filter)
118        self.jobs = jobs
119        self.onApply = onApply
120    }
121
122    var body: some View {
123        NavigationStack {
124            Form {
125                Section("Branch") {
126                    TextField("Branch", text: $draft.ref)
127                        .autocorrectionDisabled()
128                        .textInputAutocapitalization(.never)
129                        .accessibilityIdentifier("build-filter-ref")
130                }
131                Section("Status") {
132                    Menu {
133                        Button("Any status") { draft.status = nil }
134                        ForEach(BuildFilter.Status.allCases) { status in
135                            Button(status.rawValue) { draft.status = status }
136                        }
137                    } label: {
138                        menuLabel(draft.status?.rawValue ?? "Any status")
139                    }
140                    .accessibilityIdentifier("build-filter-status-menu")
141                }
142                // A repository without a CI config has no jobs to name.
143                if !jobs.isEmpty {
144                    Section("Job") {
145                        Menu {
146                            Button("Any job") { draft.job = nil }
147                            ForEach(jobs) { job in
148                                Button(job.name) { draft.job = job.name }
149                            }
150                        } label: {
151                            menuLabel(draft.job ?? "Any job")
152                        }
153                        .accessibilityIdentifier("build-filter-job-menu")
154                    }
155                }
156                Section {
157                    Button("Clear filters", role: .destructive) { draft = BuildFilter() }
158                }
159            }
160            .navigationTitle("Filter Builds")
161            .navigationBarTitleDisplayMode(.inline)
162            .toolbar {
163                ToolbarItem(placement: .confirmationAction) {
164                    Button("Done") {
165                        onApply(draft)
166                        dismiss()
167                    }
168                }
169            }
170        }
171    }
172
173    private func menuLabel(_ text: String) -> some View {
174        HStack {
175            Text(text)
176            Spacer()
177            Image(systemName: "chevron.up.chevron.down")
178                .font(.gbSans(.caption2))
179                .foregroundStyle(.secondary)
180        }
181    }
182}
183
184nonisolated enum BuildRoute: Hashable {
185    case list(repo: String)
186    case detail(repo: String, number: Int64)
187}