gitbay/Views/Repos/WebhookListView.swift

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

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}