import SwiftUI /// A repository's webhooks: list, add, remove, and the way to its /// deliveries. Reached from repo settings. struct WebhookListView: View { @State private var model: WebhookListViewModel @State private var adding = false @State private var removingHook: Webhook? init(client: GitbayClient, repo: String) { _model = State(initialValue: WebhookListViewModel(client: client, repoPath: repo)) } var body: some View { List { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } Section { NavigationLink(value: RepoRoute.webhookDeliveries(repo: model.repoPath)) { Label("Deliveries", systemImage: "paperplane") .font(.gbSans(.subheadline)) } .accessibilityIdentifier("webhook-deliveries-link") } Section { ForEach(model.state.value ?? []) { hook in VStack(alignment: .leading, spacing: 3) { Text(hook.url) .font(.gbMono(.caption)) .lineLimit(2) HStack(spacing: 6) { GBChip(hook.events, .secondary) if hook.hasSecret { GBChip("signed", .secondary) } if !hook.active { GBChip("inactive", .gbWarn) } } } .padding(.vertical, 2) .swipeActions { Button("Remove", role: .destructive) { removingHook = hook } .disabled(model.working) } } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Webhooks") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { adding = true } label: { Image(systemName: "plus") } .accessibilityLabel("Add Webhook") .disabled(model.working) .accessibilityIdentifier("webhook-add-button") } } .sheet(isPresented: $adding) { WebhookAddSheet(model: model) { adding = false } } .confirmationDialog( "Remove the webhook for \(removingHook?.url ?? "")?", isPresented: Binding( get: { removingHook != nil }, set: { if !$0 { removingHook = nil } } ), titleVisibility: .visible ) { Button("Remove", role: .destructive) { if let hook = removingHook { Task { await model.remove(id: hook.id) } } removingHook = nil } Button("Cancel", role: .cancel) {} } .task { await model.load() } .refreshable { await model.load() } } } /// `webhook add` — URL, optional signing secret, optional event list. private struct WebhookAddSheet: View { let model: WebhookListViewModel let onAdded: () -> Void @State private var url = "" @State private var secret = "" @State private var events = "" var body: some View { NavigationStack { Form { Section { TextField("https://…", text: $url) .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.URL) .accessibilityIdentifier("webhook-url") SecureField("Secret (optional)", text: $secret) .autocorrectionDisabled() .textInputAutocapitalization(.never) } footer: { Text("With a secret, every delivery is signed so the receiver can verify it.") } Section { TextField("Events", text: $events) .autocorrectionDisabled() .textInputAutocapitalization(.never) } footer: { Text("Comma-separated, such as push,issue.created. Empty means every event.") } if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } } .navigationTitle("New Webhook") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { onAdded() } .disabled(model.working) } ToolbarItem(placement: .confirmationAction) { if model.working { ProgressView() } else { Button("Add") { Task { if await model.add(url: url, secret: secret, events: events) { // A secure field that still holds text as the sheet // goes makes iOS offer to save it as a password. secret = "" await Task.yield() onAdded() } } } .disabled(url.trimmingCharacters(in: .whitespaces).isEmpty) .accessibilityIdentifier("webhook-submit") } } } } .interactiveDismissDisabled(model.working) } } /// `webhook deliveries` — recent deliveries, newest first, with a swipe /// to requeue one. struct WebhookDeliveriesView: View { @State private var model: WebhookDeliveriesViewModel init(client: GitbayClient, repo: String) { _model = State(initialValue: WebhookDeliveriesViewModel(client: client, repoPath: repo)) } var body: some View { List { if let error = model.actionError { Section { GBNotice(error, .gbWarn) } } ForEach(model.state.value ?? []) { delivery in VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(delivery.event) .font(.gbSans(.subheadline).weight(.medium)) GBChip(delivery.status, delivery.status == "delivered" ? .gbDone : .gbWarn) Spacer() if let code = delivery.lastStatus { Text(String(code)) .font(.gbMono(.caption)) .foregroundStyle(.secondary) } } Text(delivery.url) .font(.gbMono(.caption2)) .foregroundStyle(.secondary) .lineLimit(1) Text("\(delivery.attempts) attempt\(delivery.attempts == 1 ? "" : "s")" + (delivery.lastError.map { " — \($0)" } ?? "")) .font(.gbSans(.caption)) .foregroundStyle(.tertiary) .lineLimit(2) } .padding(.vertical, 2) .swipeActions { Button("Redeliver") { Task { await model.redeliver(id: delivery.id) } } .disabled(model.working) } } } .overlay { LoadStateOverlay(state: model.state) } .navigationTitle("Deliveries") .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .refreshable { await model.load() } } }