gitbay/Views/Repos/WebhookListView.swift

v1.7.0
gitbay-ios/gitbay/Views/Repos/WebhookListView.swift history · blame · raw

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}