gitbay/Views/Repos/WebhookListView.swift
218 lines · 8391 bytes
1import SwiftUI
2
3/// A repository's webhooks: list, add, remove, and the way to its
4/// deliveries. Reached from repo settings.
5struct WebhookListView: View {
6
7 @State private var model: WebhookListViewModel
8 @State private var adding = false
9 @State private var removingHook: Webhook?
10
11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: WebhookListViewModel(client: client, repoPath: repo))
13 }
14
15 var body: some View {
16 List {
17 if let error = model.actionError {
18 Section {
19 GBNotice(error, .gbWarn)
20 }
21 }
22 Section {
23 NavigationLink(value: RepoRoute.webhookDeliveries(repo: model.repoPath)) {
24 Label("Deliveries", systemImage: "paperplane")
25 .font(.gbSans(.subheadline))
26 }
27 .accessibilityIdentifier("webhook-deliveries-link")
28 }
29 Section {
30 ForEach(model.state.value ?? []) { hook in
31 VStack(alignment: .leading, spacing: 3) {
32 Text(hook.url)
33 .font(.gbMono(.caption))
34 .lineLimit(2)
35 HStack(spacing: 6) {
36 GBChip(hook.events, .secondary)
37 if hook.hasSecret {
38 GBChip("signed", .secondary)
39 }
40 if !hook.active {
41 GBChip("inactive", .gbWarn)
42 }
43 }
44 }
45 .padding(.vertical, 2)
46 .swipeActions {
47 Button("Remove", role: .destructive) {
48 removingHook = hook
49 }
50 .disabled(model.working)
51 }
52 }
53 }
54 }
55 .overlay { LoadStateOverlay(state: model.state) }
56 .navigationTitle("Webhooks")
57 .navigationBarTitleDisplayMode(.inline)
58 .toolbar {
59 ToolbarItem(placement: .topBarTrailing) {
60 Button {
61 adding = true
62 } label: {
63 Image(systemName: "plus")
64 }
65 .accessibilityLabel("Add Webhook")
66 .disabled(model.working)
67 .accessibilityIdentifier("webhook-add-button")
68 }
69 }
70 .sheet(isPresented: $adding) {
71 WebhookAddSheet(model: model) { adding = false }
72 }
73 .confirmationDialog(
74 "Remove the webhook for \(removingHook?.url ?? "")?",
75 isPresented: Binding(
76 get: { removingHook != nil },
77 set: { if !$0 { removingHook = nil } }
78 ),
79 titleVisibility: .visible
80 ) {
81 Button("Remove", role: .destructive) {
82 if let hook = removingHook {
83 Task { await model.remove(id: hook.id) }
84 }
85 removingHook = nil
86 }
87 Button("Cancel", role: .cancel) {}
88 }
89 .task { await model.load() }
90 .refreshable { await model.load() }
91 }
92}
93
94/// `webhook add` — URL, optional signing secret, optional event list.
95private struct WebhookAddSheet: View {
96 let model: WebhookListViewModel
97 let onAdded: () -> Void
98
99 @State private var url = ""
100 @State private var secret = ""
101 @State private var events = ""
102
103 var body: some View {
104 NavigationStack {
105 Form {
106 Section {
107 TextField("https://…", text: $url)
108 .autocorrectionDisabled()
109 .textInputAutocapitalization(.never)
110 .keyboardType(.URL)
111 .accessibilityIdentifier("webhook-url")
112 SecureField("Secret (optional)", text: $secret)
113 .autocorrectionDisabled()
114 .textInputAutocapitalization(.never)
115 } footer: {
116 Text("With a secret, every delivery is signed so the receiver can verify it.")
117 }
118 Section {
119 TextField("Events", text: $events)
120 .autocorrectionDisabled()
121 .textInputAutocapitalization(.never)
122 } footer: {
123 Text("Comma-separated, such as push,issue.created. Empty means every event.")
124 }
125 if let error = model.actionError {
126 Section {
127 GBNotice(error, .gbWarn)
128 }
129 }
130 }
131 .navigationTitle("New Webhook")
132 .navigationBarTitleDisplayMode(.inline)
133 .toolbar {
134 ToolbarItem(placement: .cancellationAction) {
135 Button("Cancel") { onAdded() }
136 .disabled(model.working)
137 }
138 ToolbarItem(placement: .confirmationAction) {
139 if model.working {
140 ProgressView()
141 } else {
142 Button("Add") {
143 Task {
144 if await model.add(url: url, secret: secret, events: events) {
145 // A secure field that still holds text as the sheet
146 // goes makes iOS offer to save it as a password.
147 secret = ""
148 await Task.yield()
149 onAdded()
150 }
151 }
152 }
153 .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
154 .accessibilityIdentifier("webhook-submit")
155 }
156 }
157 }
158 }
159 .interactiveDismissDisabled(model.working)
160 }
161}
162
163/// `webhook deliveries` — recent deliveries, newest first, with a swipe
164/// to requeue one.
165struct WebhookDeliveriesView: View {
166
167 @State private var model: WebhookDeliveriesViewModel
168
169 init(client: GitbayClient, repo: String) {
170 _model = State(initialValue: WebhookDeliveriesViewModel(client: client, repoPath: repo))
171 }
172
173 var body: some View {
174 List {
175 if let error = model.actionError {
176 Section {
177 GBNotice(error, .gbWarn)
178 }
179 }
180 ForEach(model.state.value ?? []) { delivery in
181 VStack(alignment: .leading, spacing: 3) {
182 HStack(spacing: 6) {
183 Text(delivery.event)
184 .font(.gbSans(.subheadline).weight(.medium))
185 GBChip(delivery.status, delivery.status == "delivered" ? .gbDone : .gbWarn)
186 Spacer()
187 if let code = delivery.lastStatus {
188 Text(String(code))
189 .font(.gbMono(.caption))
190 .foregroundStyle(.secondary)
191 }
192 }
193 Text(delivery.url)
194 .font(.gbMono(.caption2))
195 .foregroundStyle(.secondary)
196 .lineLimit(1)
197 Text("\(delivery.attempts) attempt\(delivery.attempts == 1 ? "" : "s")"
198 + (delivery.lastError.map { " — \($0)" } ?? ""))
199 .font(.gbSans(.caption))
200 .foregroundStyle(.tertiary)
201 .lineLimit(2)
202 }
203 .padding(.vertical, 2)
204 .swipeActions {
205 Button("Redeliver") {
206 Task { await model.redeliver(id: delivery.id) }
207 }
208 .disabled(model.working)
209 }
210 }
211 }
212 .overlay { LoadStateOverlay(state: model.state) }
213 .navigationTitle("Deliveries")
214 .navigationBarTitleDisplayMode(.inline)
215 .task { await model.load() }
216 .refreshable { await model.load() }
217 }
218}