gitbay/Views/Builds/BuildListView.swift

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

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