gitbay/Views/Repos/WebhookListView.swift
213 lines · 8062 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 .disabled(model.working)
66 .accessibilityIdentifier("webhook-add-button")
67 }
68 }
69 .sheet(isPresented: $adding) {
70 WebhookAddSheet(model: model) { adding = false }
71 }
72 .confirmationDialog(
73 "Remove the webhook for \(removingHook?.url ?? "")?",
74 isPresented: Binding(
75 get: { removingHook != nil },
76 set: { if !$0 { removingHook = nil } }
77 ),
78 titleVisibility: .visible
79 ) {
80 Button("Remove", role: .destructive) {
81 if let hook = removingHook {
82 Task { await model.remove(id: hook.id) }
83 }
84 removingHook = nil
85 }
86 Button("Cancel", role: .cancel) {}
87 }
88 .task { await model.load() }
89 .refreshable { await model.load() }
90 }
91}
92
93/// `webhook add` — URL, optional signing secret, optional event list.
94private struct WebhookAddSheet: View {
95 let model: WebhookListViewModel
96 let onAdded: () -> Void
97
98 @State private var url = ""
99 @State private var secret = ""
100 @State private var events = ""
101
102 var body: some View {
103 NavigationStack {
104 Form {
105 Section {
106 TextField("https://…", text: $url)
107 .autocorrectionDisabled()
108 .textInputAutocapitalization(.never)
109 .keyboardType(.URL)
110 .accessibilityIdentifier("webhook-url")
111 SecureField("Secret (optional)", text: $secret)
112 .autocorrectionDisabled()
113 .textInputAutocapitalization(.never)
114 } footer: {
115 Text("With a secret, every delivery is signed so the receiver can verify it.")
116 }
117 Section {
118 TextField("Events", text: $events)
119 .autocorrectionDisabled()
120 .textInputAutocapitalization(.never)
121 } footer: {
122 Text("Comma-separated, such as push,issue.created. Empty means every event.")
123 }
124 if let error = model.actionError {
125 Section {
126 GBNotice(error, .gbWarn)
127 }
128 }
129 }
130 .navigationTitle("New Webhook")
131 .navigationBarTitleDisplayMode(.inline)
132 .toolbar {
133 ToolbarItem(placement: .cancellationAction) {
134 Button("Cancel") { onAdded() }
135 .disabled(model.working)
136 }
137 ToolbarItem(placement: .confirmationAction) {
138 if model.working {
139 ProgressView()
140 } else {
141 Button("Add") {
142 Task {
143 if await model.add(url: url, secret: secret, events: events) {
144 onAdded()
145 }
146 }
147 }
148 .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
149 .accessibilityIdentifier("webhook-submit")
150 }
151 }
152 }
153 }
154 .interactiveDismissDisabled(model.working)
155 }
156}
157
158/// `webhook deliveries` — recent deliveries, newest first, with a swipe
159/// to requeue one.
160struct WebhookDeliveriesView: View {
161
162 @State private var model: WebhookDeliveriesViewModel
163
164 init(client: GitbayClient, repo: String) {
165 _model = State(initialValue: WebhookDeliveriesViewModel(client: client, repoPath: repo))
166 }
167
168 var body: some View {
169 List {
170 if let error = model.actionError {
171 Section {
172 GBNotice(error, .gbWarn)
173 }
174 }
175 ForEach(model.state.value ?? []) { delivery in
176 VStack(alignment: .leading, spacing: 3) {
177 HStack(spacing: 6) {
178 Text(delivery.event)
179 .font(.gbSans(.subheadline).weight(.medium))
180 GBChip(delivery.status, delivery.status == "delivered" ? .gbDone : .gbWarn)
181 Spacer()
182 if let code = delivery.lastStatus {
183 Text(String(code))
184 .font(.gbMono(.caption))
185 .foregroundStyle(.secondary)
186 }
187 }
188 Text(delivery.url)
189 .font(.gbMono(.caption2))
190 .foregroundStyle(.secondary)
191 .lineLimit(1)
192 Text("\(delivery.attempts) attempt\(delivery.attempts == 1 ? "" : "s")"
193 + (delivery.lastError.map { " — \($0)" } ?? ""))
194 .font(.gbSans(.caption))
195 .foregroundStyle(.tertiary)
196 .lineLimit(2)
197 }
198 .padding(.vertical, 2)
199 .swipeActions {
200 Button("Redeliver") {
201 Task { await model.redeliver(id: delivery.id) }
202 }
203 .disabled(model.working)
204 }
205 }
206 }
207 .overlay { LoadStateOverlay(state: model.state) }
208 .navigationTitle("Deliveries")
209 .navigationBarTitleDisplayMode(.inline)
210 .task { await model.load() }
211 .refreshable { await model.load() }
212 }
213}