@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,470 @@
1
+ /**
2
+ * Provider Connections Page
3
+ *
4
+ * Manages site-wide connections for a specific integration provider.
5
+ * Uses the provider's connectionSchema with DynamicForm for the configuration UI.
6
+ */
7
+ import { useState, useEffect, useCallback } from "react";
8
+ import { useParams } from "react-router-dom";
9
+ import {
10
+ Plus,
11
+ Settings2,
12
+ Trash2,
13
+ TestTube2,
14
+ CheckCircle2,
15
+ XCircle,
16
+ Loader2,
17
+ } from "lucide-react";
18
+ import {
19
+ PageLayout,
20
+ Card,
21
+ CardContent,
22
+ CardHeader,
23
+ CardTitle,
24
+ Button,
25
+ Dialog,
26
+ DialogContent,
27
+ DialogHeader,
28
+ DialogTitle,
29
+ DialogDescription,
30
+ DialogFooter,
31
+ DynamicIcon,
32
+ EmptyState,
33
+ Table,
34
+ TableBody,
35
+ TableCell,
36
+ TableHead,
37
+ TableHeader,
38
+ TableRow,
39
+ DynamicForm,
40
+ Input,
41
+ Label,
42
+ useToast,
43
+ ConfirmationModal,
44
+ BackLink,
45
+ type LucideIconName,
46
+ } from "@checkstack/ui";
47
+ import { useApi, rpcApiRef } from "@checkstack/frontend-api";
48
+ import { resolveRoute } from "@checkstack/common";
49
+ import {
50
+ IntegrationApi,
51
+ integrationRoutes,
52
+ type IntegrationProviderInfo,
53
+ type ProviderConnectionRedacted,
54
+ } from "@checkstack/integration-common";
55
+
56
+ export const ProviderConnectionsPage = () => {
57
+ const { providerId } = useParams<{ providerId: string }>();
58
+ const rpcApi = useApi(rpcApiRef);
59
+ const client = rpcApi.forPlugin(IntegrationApi);
60
+ const toast = useToast();
61
+
62
+ const [loading, setLoading] = useState(true);
63
+ const [provider, setProvider] = useState<
64
+ IntegrationProviderInfo | undefined
65
+ >();
66
+ const [connections, setConnections] = useState<ProviderConnectionRedacted[]>(
67
+ []
68
+ );
69
+
70
+ // Dialog states
71
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
72
+ const [editDialogOpen, setEditDialogOpen] = useState(false);
73
+ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
74
+ const [selectedConnection, setSelectedConnection] = useState<
75
+ ProviderConnectionRedacted | undefined
76
+ >();
77
+
78
+ // Form state
79
+ const [formName, setFormName] = useState("");
80
+ const [formConfig, setFormConfig] = useState<Record<string, unknown>>({});
81
+ const [saving, setSaving] = useState(false);
82
+
83
+ // Test state
84
+ const [testingId, setTestingId] = useState<string | undefined>();
85
+ const [testResults, setTestResults] = useState<
86
+ Record<string, { success: boolean; message?: string }>
87
+ >({});
88
+
89
+ // Form validation state
90
+ const [configValid, setConfigValid] = useState(false);
91
+
92
+ const fetchData = useCallback(async () => {
93
+ if (!providerId) return;
94
+
95
+ try {
96
+ const [providersResult, connectionsResult] = await Promise.all([
97
+ client.listProviders(),
98
+ client.listConnections({ providerId }),
99
+ ]);
100
+
101
+ const foundProvider = providersResult.find(
102
+ (p) => p.qualifiedId === providerId
103
+ );
104
+ setProvider(foundProvider);
105
+ setConnections(connectionsResult);
106
+ } catch (error) {
107
+ console.error("Failed to load connections:", error);
108
+ toast.error("Failed to load connections");
109
+ } finally {
110
+ setLoading(false);
111
+ }
112
+ }, [providerId, client, toast]);
113
+
114
+ useEffect(() => {
115
+ void fetchData();
116
+ }, [fetchData]);
117
+
118
+ const handleCreate = async () => {
119
+ if (!providerId || !formName.trim()) return;
120
+
121
+ setSaving(true);
122
+ try {
123
+ const newConnection = await client.createConnection({
124
+ providerId,
125
+ name: formName.trim(),
126
+ config: formConfig,
127
+ });
128
+ setConnections((prev) => [...prev, newConnection]);
129
+ setCreateDialogOpen(false);
130
+ setFormName("");
131
+ setFormConfig({});
132
+ toast.success("Connection created successfully");
133
+ } catch (error) {
134
+ console.error("Failed to create connection:", error);
135
+ toast.error("Failed to create connection");
136
+ } finally {
137
+ setSaving(false);
138
+ }
139
+ };
140
+
141
+ // Reset form when creating
142
+ const openCreateDialog = () => {
143
+ setFormName("");
144
+ setFormConfig({});
145
+ setConfigValid(false);
146
+ setCreateDialogOpen(true);
147
+ };
148
+
149
+ const handleUpdate = async () => {
150
+ if (!selectedConnection) return;
151
+
152
+ setSaving(true);
153
+ try {
154
+ const updated = await client.updateConnection({
155
+ connectionId: selectedConnection.id,
156
+ updates: {
157
+ name: formName.trim() || selectedConnection.name,
158
+ config: formConfig,
159
+ },
160
+ });
161
+ setConnections((prev) =>
162
+ prev.map((c) => (c.id === updated.id ? updated : c))
163
+ );
164
+ setEditDialogOpen(false);
165
+ setSelectedConnection(undefined);
166
+ toast.success("Connection updated successfully");
167
+ } catch (error) {
168
+ console.error("Failed to update connection:", error);
169
+ toast.error("Failed to update connection");
170
+ } finally {
171
+ setSaving(false);
172
+ }
173
+ };
174
+
175
+ const handleDelete = async () => {
176
+ if (!selectedConnection) return;
177
+
178
+ try {
179
+ await client.deleteConnection({ connectionId: selectedConnection.id });
180
+ setConnections((prev) =>
181
+ prev.filter((c) => c.id !== selectedConnection.id)
182
+ );
183
+ setDeleteConfirmOpen(false);
184
+ setSelectedConnection(undefined);
185
+ toast.success("Connection deleted");
186
+ } catch (error) {
187
+ console.error("Failed to delete connection:", error);
188
+ toast.error("Failed to delete connection");
189
+ }
190
+ };
191
+
192
+ const handleTest = async (connectionId: string) => {
193
+ setTestingId(connectionId);
194
+ try {
195
+ const result = await client.testConnection({ connectionId });
196
+ setTestResults((prev) => ({
197
+ ...prev,
198
+ [connectionId]: result,
199
+ }));
200
+ if (result.success) {
201
+ toast.success(result.message ?? "Connection test successful");
202
+ } else {
203
+ toast.error(result.message ?? "Connection test failed");
204
+ }
205
+ } catch {
206
+ setTestResults((prev) => ({
207
+ ...prev,
208
+ [connectionId]: { success: false, message: "Test failed" },
209
+ }));
210
+ toast.error("Connection test failed");
211
+ } finally {
212
+ setTestingId(undefined);
213
+ }
214
+ };
215
+
216
+ const openEditDialog = (connection: ProviderConnectionRedacted) => {
217
+ setSelectedConnection(connection);
218
+ setFormName(connection.name);
219
+ setFormConfig(connection.configPreview);
220
+ setConfigValid(true); // Existing connections should have valid config
221
+ setEditDialogOpen(true);
222
+ };
223
+
224
+ const openDeleteConfirm = (connection: ProviderConnectionRedacted) => {
225
+ setSelectedConnection(connection);
226
+ setDeleteConfirmOpen(true);
227
+ };
228
+
229
+ if (!providerId) {
230
+ return <PageLayout title="Error">Missing provider ID</PageLayout>;
231
+ }
232
+
233
+ if (!loading && !provider) {
234
+ return (
235
+ <PageLayout title="Provider Not Found">
236
+ <EmptyState
237
+ icon={<Settings2 className="h-12 w-12" />}
238
+ title="Provider not found"
239
+ description={`No provider found with ID: ${providerId}`}
240
+ />
241
+ </PageLayout>
242
+ );
243
+ }
244
+
245
+ if (!loading && !provider?.hasConnectionSchema) {
246
+ return (
247
+ <PageLayout title={provider?.displayName ?? "Provider"}>
248
+ <EmptyState
249
+ icon={<Settings2 className="h-12 w-12" />}
250
+ title="No connection management"
251
+ description="This provider does not support site-wide connections"
252
+ />
253
+ </PageLayout>
254
+ );
255
+ }
256
+
257
+ return (
258
+ <PageLayout
259
+ title={`${provider?.displayName ?? "Provider"} Connections`}
260
+ subtitle="Manage site-wide connections for this integration provider"
261
+ loading={loading}
262
+ actions={
263
+ <div className="flex items-center gap-2">
264
+ <BackLink to={resolveRoute(integrationRoutes.routes.list)}>
265
+ Back to Integrations
266
+ </BackLink>
267
+ <Button onClick={openCreateDialog}>
268
+ <Plus className="h-4 w-4 mr-2" />
269
+ New Connection
270
+ </Button>
271
+ </div>
272
+ }
273
+ >
274
+ {connections.length === 0 ? (
275
+ <EmptyState
276
+ icon={
277
+ <DynamicIcon
278
+ name={(provider?.icon ?? "Settings2") as LucideIconName}
279
+ className="h-12 w-12"
280
+ />
281
+ }
282
+ title="No connections configured"
283
+ description="Create a connection to start using this provider"
284
+ >
285
+ <Button onClick={openCreateDialog} className="mt-4">
286
+ <Plus className="h-4 w-4 mr-2" />
287
+ Create Connection
288
+ </Button>
289
+ </EmptyState>
290
+ ) : (
291
+ <Card>
292
+ <CardHeader>
293
+ <CardTitle className="flex items-center gap-2">
294
+ <DynamicIcon
295
+ name={(provider?.icon ?? "Settings2") as LucideIconName}
296
+ className="h-5 w-5"
297
+ />
298
+ Connections
299
+ </CardTitle>
300
+ </CardHeader>
301
+ <CardContent>
302
+ <Table>
303
+ <TableHeader>
304
+ <TableRow>
305
+ <TableHead>Name</TableHead>
306
+ <TableHead>Created</TableHead>
307
+ <TableHead>Status</TableHead>
308
+ <TableHead className="text-right">Actions</TableHead>
309
+ </TableRow>
310
+ </TableHeader>
311
+ <TableBody>
312
+ {connections.map((conn) => {
313
+ const testResult = testResults[conn.id];
314
+ const isTesting = testingId === conn.id;
315
+
316
+ return (
317
+ <TableRow key={conn.id}>
318
+ <TableCell className="font-medium">{conn.name}</TableCell>
319
+ <TableCell className="text-muted-foreground">
320
+ {new Date(conn.createdAt).toLocaleDateString()}
321
+ </TableCell>
322
+ <TableCell>
323
+ {testResult && (
324
+ <div className="flex items-center gap-1">
325
+ {testResult.success ? (
326
+ <CheckCircle2 className="h-4 w-4 text-green-600" />
327
+ ) : (
328
+ <XCircle className="h-4 w-4 text-red-600" />
329
+ )}
330
+ <span className="text-sm">
331
+ {testResult.success ? "Connected" : "Failed"}
332
+ </span>
333
+ </div>
334
+ )}
335
+ </TableCell>
336
+ <TableCell className="text-right">
337
+ <div className="flex items-center justify-end gap-2">
338
+ <Button
339
+ variant="ghost"
340
+ size="sm"
341
+ onClick={() => handleTest(conn.id)}
342
+ disabled={isTesting}
343
+ >
344
+ {isTesting ? (
345
+ <Loader2 className="h-4 w-4 animate-spin" />
346
+ ) : (
347
+ <TestTube2 className="h-4 w-4" />
348
+ )}
349
+ </Button>
350
+ <Button
351
+ variant="ghost"
352
+ size="sm"
353
+ onClick={() => openEditDialog(conn)}
354
+ >
355
+ <Settings2 className="h-4 w-4" />
356
+ </Button>
357
+ <Button
358
+ variant="ghost"
359
+ size="sm"
360
+ onClick={() => openDeleteConfirm(conn)}
361
+ >
362
+ <Trash2 className="h-4 w-4 text-destructive" />
363
+ </Button>
364
+ </div>
365
+ </TableCell>
366
+ </TableRow>
367
+ );
368
+ })}
369
+ </TableBody>
370
+ </Table>
371
+ </CardContent>
372
+ </Card>
373
+ )}
374
+
375
+ {/* Create Dialog */}
376
+ <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
377
+ <DialogContent className="max-w-lg">
378
+ <DialogHeader>
379
+ <DialogTitle>New Connection</DialogTitle>
380
+ <DialogDescription>
381
+ Create a new {provider?.displayName} connection
382
+ </DialogDescription>
383
+ </DialogHeader>
384
+ <div className="space-y-4 py-4">
385
+ <div className="space-y-2">
386
+ <Label htmlFor="connection-name">Connection Name</Label>
387
+ <Input
388
+ id="connection-name"
389
+ placeholder="e.g., Production Server"
390
+ value={formName}
391
+ onChange={(e) => setFormName(e.target.value)}
392
+ />
393
+ </div>
394
+ {provider?.connectionSchema && (
395
+ <DynamicForm
396
+ schema={provider.connectionSchema}
397
+ value={formConfig}
398
+ onChange={setFormConfig}
399
+ onValidChange={setConfigValid}
400
+ />
401
+ )}
402
+ </div>
403
+ <DialogFooter>
404
+ <Button
405
+ variant="outline"
406
+ onClick={() => setCreateDialogOpen(false)}
407
+ >
408
+ Cancel
409
+ </Button>
410
+ <Button
411
+ onClick={handleCreate}
412
+ disabled={!formName.trim() || !configValid || saving}
413
+ >
414
+ {saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
415
+ Create
416
+ </Button>
417
+ </DialogFooter>
418
+ </DialogContent>
419
+ </Dialog>
420
+
421
+ {/* Edit Dialog */}
422
+ <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
423
+ <DialogContent className="max-w-lg">
424
+ <DialogHeader>
425
+ <DialogTitle>Edit Connection</DialogTitle>
426
+ <DialogDescription>Update connection settings</DialogDescription>
427
+ </DialogHeader>
428
+ <div className="space-y-4 py-4">
429
+ <div className="space-y-2">
430
+ <Label htmlFor="edit-connection-name">Connection Name</Label>
431
+ <Input
432
+ id="edit-connection-name"
433
+ value={formName}
434
+ onChange={(e) => setFormName(e.target.value)}
435
+ />
436
+ </div>
437
+ {provider?.connectionSchema && (
438
+ <DynamicForm
439
+ schema={provider.connectionSchema}
440
+ value={formConfig}
441
+ onChange={setFormConfig}
442
+ onValidChange={setConfigValid}
443
+ />
444
+ )}
445
+ </div>
446
+ <DialogFooter>
447
+ <Button variant="outline" onClick={() => setEditDialogOpen(false)}>
448
+ Cancel
449
+ </Button>
450
+ <Button onClick={handleUpdate} disabled={!configValid || saving}>
451
+ {saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
452
+ Save Changes
453
+ </Button>
454
+ </DialogFooter>
455
+ </DialogContent>
456
+ </Dialog>
457
+
458
+ {/* Delete Confirmation */}
459
+ <ConfirmationModal
460
+ isOpen={deleteConfirmOpen}
461
+ onClose={() => setDeleteConfirmOpen(false)}
462
+ title="Delete Connection"
463
+ message={`Are you sure you want to delete "${selectedConnection?.name}"? This action cannot be undone.`}
464
+ confirmText="Delete"
465
+ variant="danger"
466
+ onConfirm={() => void handleDelete()}
467
+ />
468
+ </PageLayout>
469
+ );
470
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Registry for provider configuration component extensions.
3
+ * Allows integration providers to register custom React components for
4
+ * subscription configuration instead of using the generic DynamicForm.
5
+ */
6
+ import type { ComponentType } from "react";
7
+
8
+ /**
9
+ * Props passed to custom provider config components.
10
+ * @template TConfig The shape of the provider configuration
11
+ */
12
+ export interface ProviderConfigProps<
13
+ TConfig extends Record<string, unknown> = Record<string, unknown>
14
+ > {
15
+ /** Current provider configuration values */
16
+ value: TConfig;
17
+ /** Callback to update configuration values */
18
+ onChange: (value: TConfig) => void;
19
+ /** Whether the form is in a saving/loading state */
20
+ isSubmitting?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Extension registration for custom provider configuration components.
25
+ * @template TConfig The shape of the provider configuration
26
+ */
27
+ export interface ProviderConfigExtension<
28
+ TConfig extends Record<string, unknown> = Record<string, unknown>
29
+ > {
30
+ /** Qualified provider ID this extension applies to (e.g., "integration-jira.jira") */
31
+ providerId: string;
32
+ /**
33
+ * React component to render for this provider's configuration.
34
+ * Will be used instead of the generic DynamicForm.
35
+ */
36
+ ConfigComponent: ComponentType<ProviderConfigProps<TConfig>>;
37
+ }
38
+
39
+ const registeredExtensions = new Map<string, ProviderConfigExtension>();
40
+
41
+ /**
42
+ * Register a custom configuration component for a provider.
43
+ * @param extension The extension to register
44
+ */
45
+ export function registerProviderConfigExtension(
46
+ extension: ProviderConfigExtension
47
+ ): void {
48
+ registeredExtensions.set(extension.providerId, extension);
49
+ }
50
+
51
+ /**
52
+ * Get a registered configuration component for a provider.
53
+ * @param providerId Qualified provider ID (e.g., "integration-jira.jira")
54
+ * @returns The extension if registered, undefined otherwise
55
+ */
56
+ export function getProviderConfigExtension(
57
+ providerId: string
58
+ ): ProviderConfigExtension | undefined {
59
+ return registeredExtensions.get(providerId);
60
+ }
61
+
62
+ /**
63
+ * Check if a provider has a custom configuration component registered.
64
+ * @param providerId Qualified provider ID
65
+ */
66
+ export function hasProviderConfigExtension(providerId: string): boolean {
67
+ return registeredExtensions.has(providerId);
68
+ }
69
+
70
+ /**
71
+ * Get all registered provider config extensions.
72
+ */
73
+ export function getAllProviderConfigExtensions(): ProviderConfigExtension[] {
74
+ return [...registeredExtensions.values()];
75
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/frontend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }