gitbay/Views/Repos/WebhookListView.swift
214 lines · 8113 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 onAdded()
146 }
147 }
148 }
149 .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
150 .accessibilityIdentifier("webhook-submit")
151 }
152 }
153 }
154 }
155 .interactiveDismissDisabled(model.working)
156 }
157}
158
159/// `webhook deliveries` — recent deliveries, newest first, with a swipe
160/// to requeue one.
161struct WebhookDeliveriesView: View {
162
163 @State private var model: WebhookDeliveriesViewModel
164
165 init(client: GitbayClient, repo: String) {
166 _model = State(initialValue: WebhookDeliveriesViewModel(client: client, repoPath: repo))
167 }
168
169 var body: some View {
170 List {
171 if let error = model.actionError {
172 Section {
173 GBNotice(error, .gbWarn)
174 }
175 }
176 ForEach(model.state.value ?? []) { delivery in
177 VStack(alignment: .leading, spacing: 3) {
178 HStack(spacing: 6) {
179 Text(delivery.event)
180 .font(.gbSans(.subheadline).weight(.medium))
181 GBChip(delivery.status, delivery.status == "delivered" ? .gbDone : .gbWarn)
182 Spacer()
183 if let code = delivery.lastStatus {
184 Text(String(code))
185 .font(.gbMono(.caption))
186 .foregroundStyle(.secondary)
187 }
188 }
189 Text(delivery.url)
190 .font(.gbMono(.caption2))
191 .foregroundStyle(.secondary)
192 .lineLimit(1)
193 Text("\(delivery.attempts) attempt\(delivery.attempts == 1 ? "" : "s")"
194 + (delivery.lastError.map { " — \($0)" } ?? ""))
195 .font(.gbSans(.caption))
196 .foregroundStyle(.tertiary)
197 .lineLimit(2)
198 }
199 .padding(.vertical, 2)
200 .swipeActions {
201 Button("Redeliver") {
202 Task { await model.redeliver(id: delivery.id) }
203 }
204 .disabled(model.working)
205 }
206 }
207 }
208 .overlay { LoadStateOverlay(state: model.state) }
209 .navigationTitle("Deliveries")
210 .navigationBarTitleDisplayMode(.inline)
211 .task { await model.load() }
212 .refreshable { await model.load() }
213 }
214}