@checkstack/integration-frontend 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,649 @@
1
+ import { useState, useEffect, useCallback, useMemo } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { Trash2, ScrollText } from "lucide-react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogFooter,
10
+ Button,
11
+ Input,
12
+ Textarea,
13
+ DynamicForm,
14
+ DynamicIcon,
15
+ useToast,
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ Label,
22
+ ConfirmationModal,
23
+ type LucideIconName,
24
+ } from "@checkstack/ui";
25
+ import { useApi, rpcApiRef } from "@checkstack/frontend-api";
26
+ import { resolveRoute } from "@checkstack/common";
27
+ import {
28
+ IntegrationApi,
29
+ integrationRoutes,
30
+ type WebhookSubscription,
31
+ type IntegrationProviderInfo,
32
+ type IntegrationEventInfo,
33
+ type ProviderConnectionRedacted,
34
+ type PayloadProperty,
35
+ } from "@checkstack/integration-common";
36
+ import { ProviderDocumentation } from "./ProviderDocumentation";
37
+ import { getProviderConfigExtension } from "../provider-config-registry";
38
+
39
+ interface SubscriptionDialogProps {
40
+ open: boolean;
41
+ onOpenChange: (open: boolean) => void;
42
+ providers: IntegrationProviderInfo[];
43
+ /** Existing subscription for edit mode */
44
+ subscription?: WebhookSubscription;
45
+ /** Called when a new subscription is created */
46
+ onCreated?: (subscription: WebhookSubscription) => void;
47
+ /** Called when an existing subscription is updated */
48
+ onUpdated?: (subscription: WebhookSubscription) => void;
49
+ /** Called when an existing subscription is deleted */
50
+ onDeleted?: (id: string) => void;
51
+ }
52
+
53
+ export const SubscriptionDialog = ({
54
+ open,
55
+ onOpenChange,
56
+ providers,
57
+ subscription,
58
+ onCreated,
59
+ onUpdated,
60
+ onDeleted,
61
+ }: SubscriptionDialogProps) => {
62
+ const rpcApi = useApi(rpcApiRef);
63
+ const client = rpcApi.forPlugin(IntegrationApi);
64
+ const toast = useToast();
65
+
66
+ // Edit mode detection
67
+ const isEditMode = !!subscription;
68
+
69
+ const [step, setStep] = useState<"provider" | "config">("provider");
70
+ const [selectedProvider, setSelectedProvider] =
71
+ useState<IntegrationProviderInfo>();
72
+ const [events, setEvents] = useState<IntegrationEventInfo[]>([]);
73
+ const [saving, setSaving] = useState(false);
74
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
75
+
76
+ // Connection state for providers with connectionSchema
77
+ const [connections, setConnections] = useState<ProviderConnectionRedacted[]>(
78
+ []
79
+ );
80
+ const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
81
+ const [loadingConnections, setLoadingConnections] = useState(false);
82
+
83
+ // Form state
84
+ const [name, setName] = useState("");
85
+ const [description, setDescription] = useState("");
86
+ const [providerConfig, setProviderConfig] = useState<Record<string, unknown>>(
87
+ {}
88
+ );
89
+ const [selectedEventId, setSelectedEventId] = useState<string>("");
90
+ const [payloadProperties, setPayloadProperties] = useState<PayloadProperty[]>(
91
+ []
92
+ );
93
+ // Track whether DynamicForm fields are valid (all required fields filled)
94
+ const [providerConfigValid, setProviderConfigValid] = useState(false);
95
+
96
+ // Fetch events when dialog opens
97
+ const fetchEvents = useCallback(async () => {
98
+ try {
99
+ const result = await client.listEventTypes();
100
+ setEvents(result);
101
+ } catch (error) {
102
+ console.error("Failed to fetch events:", error);
103
+ }
104
+ }, [client]);
105
+
106
+ // Fetch connections for providers with connectionSchema
107
+ const fetchConnections = useCallback(
108
+ async (providerId: string) => {
109
+ setLoadingConnections(true);
110
+ try {
111
+ const result = await client.listConnections({ providerId });
112
+ setConnections(result);
113
+ // Auto-select if only one connection
114
+ if (result.length === 1) {
115
+ setSelectedConnectionId(result[0].id);
116
+ }
117
+ } catch (error) {
118
+ console.error("Failed to fetch connections:", error);
119
+ } finally {
120
+ setLoadingConnections(false);
121
+ }
122
+ },
123
+ [client]
124
+ );
125
+
126
+ useEffect(() => {
127
+ if (open) {
128
+ void fetchEvents();
129
+ }
130
+ }, [open, fetchEvents]);
131
+
132
+ // Fetch payload schema when event changes
133
+ useEffect(() => {
134
+ if (!selectedEventId) {
135
+ setPayloadProperties([]);
136
+ return;
137
+ }
138
+
139
+ const fetchPayloadSchema = async () => {
140
+ try {
141
+ const result = await client.getEventPayloadSchema({
142
+ eventId: selectedEventId,
143
+ });
144
+ setPayloadProperties(result.availableProperties);
145
+ } catch (error) {
146
+ console.error("Failed to fetch payload schema:", error);
147
+ setPayloadProperties([]);
148
+ }
149
+ };
150
+
151
+ void fetchPayloadSchema();
152
+ }, [selectedEventId, client]);
153
+
154
+ // Pre-populate form in edit mode
155
+ useEffect(() => {
156
+ if (open && subscription) {
157
+ // Find the provider for this subscription
158
+ const provider = providers.find(
159
+ (p) => p.qualifiedId === subscription.providerId
160
+ );
161
+ if (provider) {
162
+ setSelectedProvider(provider);
163
+ setStep("config"); // Skip provider selection
164
+ if (provider.hasConnectionSchema) {
165
+ void fetchConnections(provider.qualifiedId);
166
+ }
167
+ }
168
+ // Populate form fields
169
+ setName(subscription.name);
170
+ setDescription(subscription.description ?? "");
171
+ setProviderConfig(subscription.providerConfig);
172
+ setSelectedEventId(subscription.eventId);
173
+ // Set connection ID from config
174
+ const connId = subscription.providerConfig.connectionId;
175
+ if (typeof connId === "string") {
176
+ setSelectedConnectionId(connId);
177
+ }
178
+ }
179
+ }, [open, subscription, providers, fetchConnections]);
180
+
181
+ // Reset when dialog closes (only in create mode)
182
+ useEffect(() => {
183
+ if (!open && !subscription) {
184
+ setStep("provider");
185
+ setSelectedProvider(undefined);
186
+ setName("");
187
+ setDescription("");
188
+ setProviderConfig({});
189
+ setSelectedEventId("");
190
+ setPayloadProperties([]);
191
+ setConnections([]);
192
+ setSelectedConnectionId("");
193
+ setDeleteDialogOpen(false);
194
+ setProviderConfigValid(false);
195
+ }
196
+ }, [open, subscription]);
197
+
198
+ // For providers with custom config components or no configSchema,
199
+ // DynamicForm won't report validity, so assume valid
200
+ useEffect(() => {
201
+ if (!selectedProvider) return;
202
+
203
+ const hasCustomConfig = getProviderConfigExtension(
204
+ selectedProvider.qualifiedId
205
+ );
206
+ const hasNoSchema =
207
+ !selectedProvider.configSchema ||
208
+ !selectedProvider.configSchema.properties ||
209
+ Object.keys(selectedProvider.configSchema.properties).length === 0;
210
+
211
+ if (hasCustomConfig || hasNoSchema) {
212
+ setProviderConfigValid(true);
213
+ }
214
+ }, [selectedProvider]);
215
+
216
+ const handleProviderSelect = (provider: IntegrationProviderInfo) => {
217
+ setSelectedProvider(provider);
218
+ setStep("config");
219
+ // Fetch connections if provider supports them
220
+ if (provider.hasConnectionSchema) {
221
+ void fetchConnections(provider.qualifiedId);
222
+ }
223
+ };
224
+
225
+ // Handle update (edit mode)
226
+ const handleSave = async () => {
227
+ if (!subscription || !selectedProvider) return;
228
+
229
+ try {
230
+ setSaving(true);
231
+ // Include connectionId in providerConfig for providers with connections
232
+ const configWithConnection = selectedProvider.hasConnectionSchema
233
+ ? { ...providerConfig, connectionId: selectedConnectionId }
234
+ : providerConfig;
235
+
236
+ await client.updateSubscription({
237
+ id: subscription.id,
238
+ updates: {
239
+ name,
240
+ description: description || undefined,
241
+ providerConfig: configWithConnection,
242
+ eventId:
243
+ selectedEventId === subscription.eventId
244
+ ? undefined
245
+ : selectedEventId,
246
+ },
247
+ });
248
+ toast.success("Subscription updated");
249
+ onUpdated?.(subscription);
250
+ onOpenChange(false);
251
+ } catch (error) {
252
+ console.error("Failed to update subscription:", error);
253
+ toast.error("Failed to update subscription");
254
+ } finally {
255
+ setSaving(false);
256
+ }
257
+ };
258
+
259
+ // Handle delete
260
+ const handleDelete = async () => {
261
+ if (!subscription) return;
262
+
263
+ try {
264
+ await client.deleteSubscription({ id: subscription.id });
265
+ toast.success("Subscription deleted");
266
+ onDeleted?.(subscription.id);
267
+ onOpenChange(false);
268
+ } catch (error) {
269
+ console.error("Failed to delete subscription:", error);
270
+ toast.error("Failed to delete subscription");
271
+ }
272
+ };
273
+
274
+ const handleCreate = async () => {
275
+ if (!selectedProvider) return;
276
+
277
+ // For providers with connections, require a connection to be selected
278
+ if (selectedProvider.hasConnectionSchema && !selectedConnectionId) {
279
+ toast.error("Please select a connection");
280
+ return;
281
+ }
282
+
283
+ try {
284
+ setSaving(true);
285
+ // Include connectionId in providerConfig for providers with connections
286
+ const configWithConnection = selectedProvider.hasConnectionSchema
287
+ ? { ...providerConfig, connectionId: selectedConnectionId }
288
+ : providerConfig;
289
+
290
+ const result = await client.createSubscription({
291
+ name,
292
+ description: description || undefined,
293
+ providerId: selectedProvider.qualifiedId,
294
+ providerConfig: configWithConnection,
295
+ eventId: selectedEventId,
296
+ });
297
+ onCreated?.(result);
298
+ toast.success("Subscription created");
299
+ } catch (error) {
300
+ console.error("Failed to create subscription:", error);
301
+ toast.error(
302
+ error instanceof Error ? error.message : "Failed to create subscription"
303
+ );
304
+ } finally {
305
+ setSaving(false);
306
+ }
307
+ };
308
+
309
+ // Create optionsResolvers for dynamic dropdown fields (x-options-resolver)
310
+ // Uses a Proxy to handle any resolver name dynamically
311
+ const optionsResolvers = useMemo(() => {
312
+ if (!selectedProvider || !selectedConnectionId) {
313
+ return;
314
+ }
315
+
316
+ // Create a Proxy that handles any resolver name
317
+ return new Proxy(
318
+ {},
319
+ {
320
+ get: (_target, resolverName: string) => {
321
+ // Return a resolver function for this resolver name
322
+ return async (formValues: Record<string, unknown>) => {
323
+ try {
324
+ const result = await client.getConnectionOptions({
325
+ providerId: selectedProvider.qualifiedId,
326
+ connectionId: selectedConnectionId,
327
+ resolverName,
328
+ context: formValues,
329
+ });
330
+ return result.map((opt) => ({
331
+ value: opt.value,
332
+ label: opt.label,
333
+ }));
334
+ } catch (error) {
335
+ console.error(
336
+ `Failed to fetch options for ${resolverName}:`,
337
+ error
338
+ );
339
+ return [];
340
+ }
341
+ };
342
+ },
343
+ has: () => true, // All resolver names are valid
344
+ }
345
+ ) as Record<
346
+ string,
347
+ (
348
+ formValues: Record<string, unknown>
349
+ ) => Promise<{ value: string; label: string }[]>
350
+ >;
351
+ }, [client, selectedProvider, selectedConnectionId]);
352
+
353
+ return (
354
+ <>
355
+ <Dialog
356
+ open={open}
357
+ onOpenChange={(isOpen) => {
358
+ // Don't close the dialog if the delete confirmation is open
359
+ if (!isOpen && deleteDialogOpen) return;
360
+ onOpenChange(isOpen);
361
+ }}
362
+ >
363
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
364
+ <DialogHeader>
365
+ <DialogTitle>
366
+ {isEditMode
367
+ ? `Edit ${selectedProvider?.displayName ?? "Subscription"}`
368
+ : step === "provider"
369
+ ? "Select Provider"
370
+ : `Configure ${
371
+ selectedProvider?.displayName ?? "Subscription"
372
+ }`}
373
+ </DialogTitle>
374
+ </DialogHeader>
375
+
376
+ {step === "provider" ? (
377
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-4">
378
+ {providers.length === 0 ? (
379
+ <div className="col-span-full text-center text-muted-foreground py-8">
380
+ No providers available. Install provider plugins to enable
381
+ webhook delivery.
382
+ </div>
383
+ ) : (
384
+ providers.map((provider) => (
385
+ <button
386
+ key={provider.qualifiedId}
387
+ onClick={() => handleProviderSelect(provider)}
388
+ className="flex items-center gap-4 p-4 border rounded-lg hover:bg-muted transition-colors text-left"
389
+ >
390
+ <div className="p-3 rounded-lg bg-muted">
391
+ <DynamicIcon
392
+ name={(provider.icon ?? "Webhook") as LucideIconName}
393
+ className="h-6 w-6"
394
+ />
395
+ </div>
396
+ <div>
397
+ <div className="font-medium">{provider.displayName}</div>
398
+ {provider.description && (
399
+ <div className="text-sm text-muted-foreground">
400
+ {provider.description}
401
+ </div>
402
+ )}
403
+ </div>
404
+ </button>
405
+ ))
406
+ )}
407
+ </div>
408
+ ) : (
409
+ <div className="space-y-6 py-4">
410
+ {/* Basic Info */}
411
+ <div className="space-y-4">
412
+ <div>
413
+ <label className="block text-sm font-medium mb-1">
414
+ Name <span className="text-destructive">*</span>
415
+ </label>
416
+ <Input
417
+ type="text"
418
+ value={name}
419
+ onChange={(e) => setName(e.target.value)}
420
+ placeholder="My Webhook"
421
+ />
422
+ </div>
423
+ <div>
424
+ <label className="block text-sm font-medium mb-1">
425
+ Description
426
+ </label>
427
+ <Textarea
428
+ value={description}
429
+ onChange={(e) => setDescription(e.target.value)}
430
+ placeholder="Optional description"
431
+ rows={2}
432
+ />
433
+ </div>
434
+ </div>
435
+
436
+ {/* Event Selection (required) */}
437
+ <div>
438
+ <Label className="mb-2">
439
+ Event <span className="text-destructive">*</span>
440
+ </Label>
441
+ <Select
442
+ value={selectedEventId}
443
+ onValueChange={setSelectedEventId}
444
+ >
445
+ <SelectTrigger>
446
+ <SelectValue placeholder="Select an event" />
447
+ </SelectTrigger>
448
+ <SelectContent>
449
+ {events.map((event) => (
450
+ <SelectItem key={event.eventId} value={event.eventId}>
451
+ <div>
452
+ <div>{event.displayName}</div>
453
+ {event.description && (
454
+ <div className="text-xs text-muted-foreground">
455
+ {event.description}
456
+ </div>
457
+ )}
458
+ </div>
459
+ </SelectItem>
460
+ ))}
461
+ </SelectContent>
462
+ </Select>
463
+ {events.length === 0 && (
464
+ <div className="text-muted-foreground text-sm mt-2">
465
+ No events registered. Plugins will register events.
466
+ </div>
467
+ )}
468
+ </div>
469
+
470
+ {/* Connection Selection (for providers with connectionSchema) */}
471
+ {selectedProvider?.hasConnectionSchema && (
472
+ <div>
473
+ <Label className="mb-2">
474
+ Connection <span className="text-destructive">*</span>
475
+ </Label>
476
+ {loadingConnections ? (
477
+ <div className="text-sm text-muted-foreground py-2">
478
+ Loading connections...
479
+ </div>
480
+ ) : connections.length === 0 ? (
481
+ <div className="border rounded-md p-4 bg-muted/50">
482
+ <p className="text-sm text-muted-foreground mb-2">
483
+ No connections configured for this provider.
484
+ </p>
485
+ <Button variant="outline" size="sm" asChild>
486
+ <Link
487
+ to={resolveRoute(
488
+ integrationRoutes.routes.connections,
489
+ {
490
+ providerId: selectedProvider.qualifiedId,
491
+ }
492
+ )}
493
+ >
494
+ Configure Connections
495
+ </Link>
496
+ </Button>
497
+ </div>
498
+ ) : (
499
+ <Select
500
+ value={selectedConnectionId}
501
+ onValueChange={setSelectedConnectionId}
502
+ >
503
+ <SelectTrigger>
504
+ <SelectValue placeholder="Select a connection" />
505
+ </SelectTrigger>
506
+ <SelectContent>
507
+ {connections.map((conn) => (
508
+ <SelectItem key={conn.id} value={conn.id}>
509
+ {conn.name}
510
+ </SelectItem>
511
+ ))}
512
+ </SelectContent>
513
+ </Select>
514
+ )}
515
+ </div>
516
+ )}
517
+
518
+ {/* Provider Config */}
519
+ {selectedProvider &&
520
+ (() => {
521
+ // Check if provider has a custom config component
522
+ const extension = getProviderConfigExtension(
523
+ selectedProvider.qualifiedId
524
+ );
525
+
526
+ if (extension) {
527
+ // Render custom component
528
+ const CustomConfig = extension.ConfigComponent;
529
+ return (
530
+ <div>
531
+ <label className="block text-sm font-medium mb-2">
532
+ Provider Configuration
533
+ </label>
534
+ <div className="border rounded-md p-4">
535
+ <CustomConfig
536
+ value={providerConfig}
537
+ onChange={setProviderConfig}
538
+ isSubmitting={saving}
539
+ />
540
+ </div>
541
+ </div>
542
+ );
543
+ }
544
+
545
+ // Fall back to DynamicForm for providers without custom component
546
+ if (selectedProvider.configSchema) {
547
+ return (
548
+ <div>
549
+ <label className="block text-sm font-medium mb-2">
550
+ Provider Configuration
551
+ </label>
552
+ <div className="border rounded-md p-4">
553
+ <DynamicForm
554
+ schema={selectedProvider.configSchema}
555
+ value={providerConfig}
556
+ onChange={setProviderConfig}
557
+ onValidChange={setProviderConfigValid}
558
+ optionsResolvers={optionsResolvers}
559
+ templateProperties={payloadProperties}
560
+ />
561
+ </div>
562
+ </div>
563
+ );
564
+ }
565
+
566
+ return <></>;
567
+ })()}
568
+
569
+ {/* Provider Documentation */}
570
+ {selectedProvider && (
571
+ <ProviderDocumentation provider={selectedProvider} />
572
+ )}
573
+ </div>
574
+ )}
575
+
576
+ <DialogFooter className="flex-col sm:flex-row gap-2">
577
+ {/* Left side: Delete and View Logs in edit mode */}
578
+ {isEditMode && (
579
+ <div className="flex gap-2 mr-auto">
580
+ <Button
581
+ variant="destructive"
582
+ onClick={() => setDeleteDialogOpen(true)}
583
+ >
584
+ <Trash2 className="h-4 w-4 mr-2" />
585
+ Delete
586
+ </Button>
587
+ <Link
588
+ to={resolveRoute(integrationRoutes.routes.deliveryLogs, {
589
+ subscriptionId: subscription.id,
590
+ })}
591
+ >
592
+ <Button variant="outline">
593
+ <ScrollText className="h-4 w-4 mr-2" />
594
+ View Logs
595
+ </Button>
596
+ </Link>
597
+ </div>
598
+ )}
599
+
600
+ {/* Right side: Cancel, Back, Create/Save */}
601
+ {step === "config" && !isEditMode && (
602
+ <Button variant="outline" onClick={() => setStep("provider")}>
603
+ Back
604
+ </Button>
605
+ )}
606
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
607
+ Cancel
608
+ </Button>
609
+ {step === "config" && (
610
+ <Button
611
+ onClick={() =>
612
+ void (isEditMode ? handleSave() : handleCreate())
613
+ }
614
+ disabled={
615
+ !name.trim() ||
616
+ !selectedEventId ||
617
+ !providerConfigValid ||
618
+ saving
619
+ }
620
+ >
621
+ {saving
622
+ ? isEditMode
623
+ ? "Saving..."
624
+ : "Creating..."
625
+ : isEditMode
626
+ ? "Save Changes"
627
+ : "Create Subscription"}
628
+ </Button>
629
+ )}
630
+ </DialogFooter>
631
+ </DialogContent>
632
+ </Dialog>
633
+
634
+ {/* Delete Confirmation Modal - rendered outside Dialog to fix z-index */}
635
+ <ConfirmationModal
636
+ isOpen={deleteDialogOpen}
637
+ onClose={() => setDeleteDialogOpen(false)}
638
+ title="Delete Subscription"
639
+ message={`Are you sure you want to delete "${subscription?.name}"? This action cannot be undone.`}
640
+ confirmText="Delete"
641
+ variant="danger"
642
+ onConfirm={() => void handleDelete()}
643
+ />
644
+ </>
645
+ );
646
+ };
647
+
648
+ // Export with original name for backwards compatibility
649
+ export const CreateSubscriptionDialog = SubscriptionDialog;
@@ -0,0 +1,35 @@
1
+ import { useNavigate } from "react-router-dom";
2
+ import { Webhook } from "lucide-react";
3
+ import { DropdownMenuItem } from "@checkstack/ui";
4
+ import type { UserMenuItemsContext } from "@checkstack/frontend-api";
5
+ import { qualifyPermissionId, resolveRoute } from "@checkstack/common";
6
+ import {
7
+ integrationRoutes,
8
+ permissions,
9
+ pluginMetadata,
10
+ } from "@checkstack/integration-common";
11
+ import React from "react";
12
+
13
+ export const IntegrationMenuItem = ({
14
+ permissions: userPerms,
15
+ }: UserMenuItemsContext) => {
16
+ const navigate = useNavigate();
17
+ const qualifiedId = qualifyPermissionId(
18
+ pluginMetadata,
19
+ permissions.integrationManage
20
+ );
21
+ const allowed = userPerms.includes("*") || userPerms.includes(qualifiedId);
22
+
23
+ if (!allowed) {
24
+ return <React.Fragment />;
25
+ }
26
+
27
+ return (
28
+ <DropdownMenuItem
29
+ onClick={() => navigate(resolveRoute(integrationRoutes.routes.list))}
30
+ icon={<Webhook className="h-4 w-4" />}
31
+ >
32
+ Integrations
33
+ </DropdownMenuItem>
34
+ );
35
+ };