@checkstack/integration-frontend 0.4.5 → 0.5.1

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