@checkstack/integration-frontend 0.4.4 → 0.5.0

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