@checkstack/integration-frontend 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # @checkstack/integration-frontend
2
2
 
3
+ ## 0.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [4eed42d]
8
+ - @checkstack/frontend-api@0.3.0
9
+ - @checkstack/ui@0.2.2
10
+
11
+ ## 0.2.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 7a23261: ## TanStack Query Integration
16
+
17
+ Migrated all frontend components to use `usePluginClient` hook with TanStack Query integration, replacing the legacy `forPlugin()` pattern.
18
+
19
+ ### New Features
20
+
21
+ - **`usePluginClient` hook**: Provides type-safe access to plugin APIs with `.useQuery()` and `.useMutation()` methods
22
+ - **Automatic request deduplication**: Multiple components requesting the same data share a single network request
23
+ - **Built-in caching**: Configurable stale time and cache duration per query
24
+ - **Loading/error states**: TanStack Query provides `isLoading`, `error`, `isRefetching` states automatically
25
+ - **Background refetching**: Stale data is automatically refreshed when components mount
26
+
27
+ ### Contract Changes
28
+
29
+ All RPC contracts now require `operationType: "query"` or `operationType: "mutation"` metadata:
30
+
31
+ ```typescript
32
+ const getItems = proc()
33
+ .meta({ operationType: "query", access: [access.read] })
34
+ .output(z.array(itemSchema))
35
+ .query();
36
+
37
+ const createItem = proc()
38
+ .meta({ operationType: "mutation", access: [access.manage] })
39
+ .input(createItemSchema)
40
+ .output(itemSchema)
41
+ .mutation();
42
+ ```
43
+
44
+ ### Migration
45
+
46
+ ```typescript
47
+ // Before (forPlugin pattern)
48
+ const api = useApi(myPluginApiRef);
49
+ const [items, setItems] = useState<Item[]>([]);
50
+ useEffect(() => {
51
+ api.getItems().then(setItems);
52
+ }, [api]);
53
+
54
+ // After (usePluginClient pattern)
55
+ const client = usePluginClient(MyPluginApi);
56
+ const { data: items, isLoading } = client.getItems.useQuery({});
57
+ ```
58
+
59
+ ### Bug Fixes
60
+
61
+ - Fixed `rpc.test.ts` test setup for middleware type inference
62
+ - Fixed `SearchDialog` to use `setQuery` instead of deprecated `search` method
63
+ - Fixed null→undefined warnings in notification and queue frontends
64
+
65
+ ### Patch Changes
66
+
67
+ - Updated dependencies [7a23261]
68
+ - @checkstack/frontend-api@0.2.0
69
+ - @checkstack/common@0.3.0
70
+ - @checkstack/integration-common@0.2.0
71
+ - @checkstack/ui@0.2.1
72
+ - @checkstack/signal-frontend@0.0.7
73
+
3
74
  ## 0.1.0
4
75
 
5
76
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/integration-frontend",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "main": "src/index.tsx",
6
6
  "checkstack": {
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useMemo } from "react";
1
+ import { useState, useEffect, useMemo } from "react";
2
2
  import { Link } from "react-router-dom";
3
3
  import { Trash2, ScrollText } from "lucide-react";
4
4
  import {
@@ -23,15 +23,13 @@ import {
23
23
  ConfirmationModal,
24
24
  type LucideIconName,
25
25
  } from "@checkstack/ui";
26
- import { useApi, rpcApiRef } from "@checkstack/frontend-api";
26
+ import { usePluginClient } from "@checkstack/frontend-api";
27
27
  import { resolveRoute } from "@checkstack/common";
28
28
  import {
29
29
  IntegrationApi,
30
30
  integrationRoutes,
31
31
  type WebhookSubscription,
32
32
  type IntegrationProviderInfo,
33
- type IntegrationEventInfo,
34
- type ProviderConnectionRedacted,
35
33
  type PayloadProperty,
36
34
  } from "@checkstack/integration-common";
37
35
  import { ProviderDocumentation } from "./ProviderDocumentation";
@@ -60,8 +58,7 @@ export const SubscriptionDialog = ({
60
58
  onUpdated,
61
59
  onDeleted,
62
60
  }: SubscriptionDialogProps) => {
63
- const rpcApi = useApi(rpcApiRef);
64
- const client = rpcApi.forPlugin(IntegrationApi);
61
+ const client = usePluginClient(IntegrationApi);
65
62
  const toast = useToast();
66
63
 
67
64
  // Edit mode detection
@@ -70,16 +67,11 @@ export const SubscriptionDialog = ({
70
67
  const [step, setStep] = useState<"provider" | "config">("provider");
71
68
  const [selectedProvider, setSelectedProvider] =
72
69
  useState<IntegrationProviderInfo>();
73
- const [events, setEvents] = useState<IntegrationEventInfo[]>([]);
74
70
  const [saving, setSaving] = useState(false);
75
71
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
76
72
 
77
73
  // Connection state for providers with connectionSchema
78
- const [connections, setConnections] = useState<ProviderConnectionRedacted[]>(
79
- []
80
- );
81
74
  const [selectedConnectionId, setSelectedConnectionId] = useState<string>("");
82
- const [loadingConnections, setLoadingConnections] = useState(false);
83
75
 
84
76
  // Form state
85
77
  const [name, setName] = useState("");
@@ -88,69 +80,78 @@ export const SubscriptionDialog = ({
88
80
  {}
89
81
  );
90
82
  const [selectedEventId, setSelectedEventId] = useState<string>("");
91
- const [payloadProperties, setPayloadProperties] = useState<PayloadProperty[]>(
92
- []
93
- );
94
83
  // Track whether DynamicForm fields are valid (all required fields filled)
95
84
  const [providerConfigValid, setProviderConfigValid] = useState(false);
96
85
 
97
- // Fetch events when dialog opens
98
- const fetchEvents = useCallback(async () => {
99
- try {
100
- const result = await client.listEventTypes();
101
- setEvents(result);
102
- } catch (error) {
103
- console.error("Failed to fetch events:", error);
104
- }
105
- }, [client]);
106
-
107
- // Fetch connections for providers with connectionSchema
108
- const fetchConnections = useCallback(
109
- async (providerId: string) => {
110
- setLoadingConnections(true);
111
- try {
112
- const result = await client.listConnections({ providerId });
113
- setConnections(result);
114
- // Auto-select if only one connection
115
- if (result.length === 1) {
116
- setSelectedConnectionId(result[0].id);
117
- }
118
- } catch (error) {
119
- console.error("Failed to fetch connections:", error);
120
- } finally {
121
- setLoadingConnections(false);
122
- }
123
- },
124
- [client]
86
+ // Queries using hooks
87
+ const { data: events = [] } = client.listEventTypes.useQuery(
88
+ {},
89
+ { enabled: open }
125
90
  );
126
91
 
127
- useEffect(() => {
128
- if (open) {
129
- void fetchEvents();
130
- }
131
- }, [open, fetchEvents]);
92
+ const { data: connections = [], isLoading: loadingConnections } =
93
+ client.listConnections.useQuery(
94
+ { providerId: selectedProvider?.qualifiedId ?? "" },
95
+ { enabled: open && !!selectedProvider?.hasConnectionSchema }
96
+ );
132
97
 
133
- // Fetch payload schema when event changes
134
- useEffect(() => {
135
- if (!selectedEventId) {
136
- setPayloadProperties([]);
137
- return;
138
- }
98
+ const { data: payloadSchemaData } = client.getEventPayloadSchema.useQuery(
99
+ { eventId: selectedEventId },
100
+ { enabled: open && !!selectedEventId }
101
+ );
139
102
 
140
- const fetchPayloadSchema = async () => {
141
- try {
142
- const result = await client.getEventPayloadSchema({
143
- eventId: selectedEventId,
144
- });
145
- setPayloadProperties(result.availableProperties);
146
- } catch (error) {
147
- console.error("Failed to fetch payload schema:", error);
148
- setPayloadProperties([]);
149
- }
150
- };
103
+ const payloadProperties: PayloadProperty[] =
104
+ payloadSchemaData?.availableProperties ?? [];
105
+
106
+ // Mutations
107
+ const createMutation = client.createSubscription.useMutation({
108
+ onSuccess: (result) => {
109
+ onCreated?.(result);
110
+ toast.success("Subscription created");
111
+ setSaving(false);
112
+ },
113
+ onError: (error) => {
114
+ toast.error(
115
+ error instanceof Error ? error.message : "Failed to create subscription"
116
+ );
117
+ setSaving(false);
118
+ },
119
+ });
151
120
 
152
- void fetchPayloadSchema();
153
- }, [selectedEventId, client]);
121
+ const updateMutation = client.updateSubscription.useMutation({
122
+ onSuccess: () => {
123
+ toast.success("Subscription updated");
124
+ onUpdated?.(subscription!);
125
+ onOpenChange(false);
126
+ setSaving(false);
127
+ },
128
+ onError: (error) => {
129
+ toast.error(
130
+ error instanceof Error ? error.message : "Failed to update subscription"
131
+ );
132
+ setSaving(false);
133
+ },
134
+ });
135
+
136
+ const deleteMutation = client.deleteSubscription.useMutation({
137
+ onSuccess: () => {
138
+ toast.success("Subscription deleted");
139
+ onDeleted?.(subscription!.id);
140
+ onOpenChange(false);
141
+ },
142
+ onError: (error) => {
143
+ toast.error(
144
+ error instanceof Error ? error.message : "Failed to delete subscription"
145
+ );
146
+ },
147
+ });
148
+
149
+ // Auto-select if only one connection
150
+ useEffect(() => {
151
+ if (connections.length === 1 && !selectedConnectionId) {
152
+ setSelectedConnectionId(connections[0].id);
153
+ }
154
+ }, [connections, selectedConnectionId]);
154
155
 
155
156
  // Pre-populate form in edit mode
156
157
  useEffect(() => {
@@ -162,9 +163,6 @@ export const SubscriptionDialog = ({
162
163
  if (provider) {
163
164
  setSelectedProvider(provider);
164
165
  setStep("config"); // Skip provider selection
165
- if (provider.hasConnectionSchema) {
166
- void fetchConnections(provider.qualifiedId);
167
- }
168
166
  }
169
167
  // Populate form fields
170
168
  setName(subscription.name);
@@ -177,7 +175,7 @@ export const SubscriptionDialog = ({
177
175
  setSelectedConnectionId(connId);
178
176
  }
179
177
  }
180
- }, [open, subscription, providers, fetchConnections]);
178
+ }, [open, subscription, providers]);
181
179
 
182
180
  // Reset when dialog closes (only in create mode)
183
181
  useEffect(() => {
@@ -188,8 +186,6 @@ export const SubscriptionDialog = ({
188
186
  setDescription("");
189
187
  setProviderConfig({});
190
188
  setSelectedEventId("");
191
- setPayloadProperties([]);
192
- setConnections([]);
193
189
  setSelectedConnectionId("");
194
190
  setDeleteDialogOpen(false);
195
191
  setProviderConfigValid(false);
@@ -217,62 +213,39 @@ export const SubscriptionDialog = ({
217
213
  const handleProviderSelect = (provider: IntegrationProviderInfo) => {
218
214
  setSelectedProvider(provider);
219
215
  setStep("config");
220
- // Fetch connections if provider supports them
221
- if (provider.hasConnectionSchema) {
222
- void fetchConnections(provider.qualifiedId);
223
- }
224
216
  };
225
217
 
226
218
  // Handle update (edit mode)
227
- const handleSave = async () => {
219
+ const handleSave = () => {
228
220
  if (!subscription || !selectedProvider) return;
229
221
 
230
- try {
231
- setSaving(true);
232
- // Include connectionId in providerConfig for providers with connections
233
- const configWithConnection = selectedProvider.hasConnectionSchema
234
- ? { ...providerConfig, connectionId: selectedConnectionId }
235
- : providerConfig;
236
-
237
- await client.updateSubscription({
238
- id: subscription.id,
239
- updates: {
240
- name,
241
- description: description || undefined,
242
- providerConfig: configWithConnection,
243
- eventId:
244
- selectedEventId === subscription.eventId
245
- ? undefined
246
- : selectedEventId,
247
- },
248
- });
249
- toast.success("Subscription updated");
250
- onUpdated?.(subscription);
251
- onOpenChange(false);
252
- } catch (error) {
253
- console.error("Failed to update subscription:", error);
254
- toast.error("Failed to update subscription");
255
- } finally {
256
- setSaving(false);
257
- }
222
+ setSaving(true);
223
+ // Include connectionId in providerConfig for providers with connections
224
+ const configWithConnection = selectedProvider.hasConnectionSchema
225
+ ? { ...providerConfig, connectionId: selectedConnectionId }
226
+ : providerConfig;
227
+
228
+ updateMutation.mutate({
229
+ id: subscription.id,
230
+ updates: {
231
+ name,
232
+ description: description || undefined,
233
+ providerConfig: configWithConnection,
234
+ eventId:
235
+ selectedEventId === subscription.eventId
236
+ ? undefined
237
+ : selectedEventId,
238
+ },
239
+ });
258
240
  };
259
241
 
260
242
  // Handle delete
261
- const handleDelete = async () => {
243
+ const handleDelete = () => {
262
244
  if (!subscription) return;
263
-
264
- try {
265
- await client.deleteSubscription({ id: subscription.id });
266
- toast.success("Subscription deleted");
267
- onDeleted?.(subscription.id);
268
- onOpenChange(false);
269
- } catch (error) {
270
- console.error("Failed to delete subscription:", error);
271
- toast.error("Failed to delete subscription");
272
- }
245
+ deleteMutation.mutate({ id: subscription.id });
273
246
  };
274
247
 
275
- const handleCreate = async () => {
248
+ const handleCreate = () => {
276
249
  if (!selectedProvider) return;
277
250
 
278
251
  // For providers with connections, require a connection to be selected
@@ -281,32 +254,24 @@ export const SubscriptionDialog = ({
281
254
  return;
282
255
  }
283
256
 
284
- try {
285
- setSaving(true);
286
- // Include connectionId in providerConfig for providers with connections
287
- const configWithConnection = selectedProvider.hasConnectionSchema
288
- ? { ...providerConfig, connectionId: selectedConnectionId }
289
- : providerConfig;
290
-
291
- const result = await client.createSubscription({
292
- name,
293
- description: description || undefined,
294
- providerId: selectedProvider.qualifiedId,
295
- providerConfig: configWithConnection,
296
- eventId: selectedEventId,
297
- });
298
- onCreated?.(result);
299
- toast.success("Subscription created");
300
- } catch (error) {
301
- console.error("Failed to create subscription:", error);
302
- toast.error(
303
- error instanceof Error ? error.message : "Failed to create subscription"
304
- );
305
- } finally {
306
- setSaving(false);
307
- }
257
+ setSaving(true);
258
+ // Include connectionId in providerConfig for providers with connections
259
+ const configWithConnection = selectedProvider.hasConnectionSchema
260
+ ? { ...providerConfig, connectionId: selectedConnectionId }
261
+ : providerConfig;
262
+
263
+ createMutation.mutate({
264
+ name,
265
+ description: description || undefined,
266
+ providerId: selectedProvider.qualifiedId,
267
+ providerConfig: configWithConnection,
268
+ eventId: selectedEventId,
269
+ });
308
270
  };
309
271
 
272
+ // Mutation for fetching dynamic dropdown options (called at component level)
273
+ const getOptionsMutation = client.getConnectionOptions.useMutation();
274
+
310
275
  // Create optionsResolvers for dynamic dropdown fields (x-options-resolver)
311
276
  // Uses a Proxy to handle any resolver name dynamically
312
277
  const optionsResolvers = useMemo(() => {
@@ -319,10 +284,10 @@ export const SubscriptionDialog = ({
319
284
  {},
320
285
  {
321
286
  get: (_target, resolverName: string) => {
322
- // Return a resolver function for this resolver name
287
+ // Return a resolver function that uses mutateAsync from the hook defined above
323
288
  return async (formValues: Record<string, unknown>) => {
324
289
  try {
325
- const result = await client.getConnectionOptions({
290
+ const result = await getOptionsMutation.mutateAsync({
326
291
  providerId: selectedProvider.qualifiedId,
327
292
  connectionId: selectedConnectionId,
328
293
  resolverName,
@@ -349,7 +314,8 @@ export const SubscriptionDialog = ({
349
314
  formValues: Record<string, unknown>
350
315
  ) => Promise<{ value: string; label: string }[]>
351
316
  >;
352
- }, [client, selectedProvider, selectedConnectionId]);
317
+ // Note: getOptionsMutation intentionally omitted - mutation objects change on every render
318
+ }, [selectedProvider, selectedConnectionId]);
353
319
 
354
320
  return (
355
321
  <>
@@ -21,9 +21,10 @@ import {
21
21
  TableRow,
22
22
  useToast,
23
23
  usePagination,
24
+ usePaginationSync,
24
25
  BackLink,
25
26
  } from "@checkstack/ui";
26
- import { useApi, rpcApiRef } from "@checkstack/frontend-api";
27
+ import { usePluginClient } from "@checkstack/frontend-api";
27
28
  import { resolveRoute } from "@checkstack/common";
28
29
  import {
29
30
  IntegrationApi,
@@ -58,49 +59,56 @@ const statusConfig: Record<
58
59
  };
59
60
 
60
61
  export const DeliveryLogsPage = () => {
61
- const rpcApi = useApi(rpcApiRef);
62
- const client = rpcApi.forPlugin(IntegrationApi);
62
+ const integrationClient = usePluginClient(IntegrationApi);
63
63
  const toast = useToast();
64
64
 
65
65
  const [retrying, setRetrying] = useState<string>();
66
66
 
67
- const {
68
- items: logs,
69
- loading,
70
- pagination,
71
- } = usePagination({
72
- fetchFn: async ({ limit, offset }) => {
73
- const page = Math.floor(offset / limit) + 1;
74
- return client.getDeliveryLogs({ page, pageSize: limit });
75
- },
76
- getItems: (response) => response.logs,
77
- getTotal: (response) => response.total,
78
- defaultLimit: 20,
79
- });
67
+ // Pagination state
68
+ const pagination = usePagination({ defaultLimit: 20 });
69
+
70
+ // Fetch data with useQuery
71
+ const page = Math.floor(pagination.offset / pagination.limit) + 1;
72
+ const { data, isLoading, refetch } =
73
+ integrationClient.getDeliveryLogs.useQuery({
74
+ page,
75
+ pageSize: pagination.limit,
76
+ });
77
+
78
+ // Sync total from response
79
+ usePaginationSync(pagination, data?.total);
80
+
81
+ const logs = data?.logs ?? [];
80
82
 
81
- const handleRetry = async (logId: string) => {
82
- try {
83
- setRetrying(logId);
84
- const result = await client.retryDelivery({ logId });
83
+ // Retry mutation
84
+ const retryMutation = integrationClient.retryDelivery.useMutation({
85
+ onSuccess: (result) => {
85
86
  if (result.success) {
86
87
  toast.success("Delivery re-queued");
87
- pagination.refetch();
88
+ void refetch();
88
89
  } else {
89
90
  toast.error(result.message ?? "Failed to retry delivery");
90
91
  }
91
- } catch (error) {
92
+ },
93
+ onError: (error) => {
92
94
  console.error("Failed to retry delivery:", error);
93
95
  toast.error("Failed to retry delivery");
94
- } finally {
96
+ },
97
+ onSettled: () => {
95
98
  setRetrying(undefined);
96
- }
99
+ },
100
+ });
101
+
102
+ const handleRetry = (logId: string) => {
103
+ setRetrying(logId);
104
+ retryMutation.mutate({ logId });
97
105
  };
98
106
 
99
107
  return (
100
108
  <PageLayout
101
109
  title="Delivery Logs"
102
110
  subtitle="View and manage webhook delivery attempts"
103
- loading={loading}
111
+ loading={isLoading}
104
112
  actions={
105
113
  <BackLink to={resolveRoute(integrationRoutes.routes.list)}>
106
114
  Back to Subscriptions
@@ -115,7 +123,7 @@ export const DeliveryLogsPage = () => {
115
123
  icon={<FileText className="h-5 w-5" />}
116
124
  />
117
125
 
118
- {logs.length === 0 && !loading ? (
126
+ {logs.length === 0 && !isLoading ? (
119
127
  <Card className="p-8">
120
128
  <div className="text-center text-muted-foreground">
121
129
  No delivery logs found
@@ -180,7 +188,7 @@ export const DeliveryLogsPage = () => {
180
188
  <Button
181
189
  variant="ghost"
182
190
  size="sm"
183
- onClick={() => void handleRetry(log.id)}
191
+ onClick={() => handleRetry(log.id)}
184
192
  disabled={retrying === log.id}
185
193
  >
186
194
  <RefreshCw
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { Link, useSearchParams } from "react-router-dom";
3
3
  import { Plus, Webhook, ArrowRight, Activity } from "lucide-react";
4
4
  import {
@@ -19,7 +19,7 @@ import {
19
19
  useToast,
20
20
  type LucideIconName,
21
21
  } from "@checkstack/ui";
22
- import { useApi, rpcApiRef } from "@checkstack/frontend-api";
22
+ import { usePluginClient } from "@checkstack/frontend-api";
23
23
  import { resolveRoute } from "@checkstack/common";
24
24
  import {
25
25
  IntegrationApi,
@@ -30,48 +30,44 @@ import {
30
30
  import { SubscriptionDialog } from "../components/CreateSubscriptionDialog";
31
31
 
32
32
  export const IntegrationsPage = () => {
33
- const rpcApi = useApi(rpcApiRef);
34
- const client = rpcApi.forPlugin(IntegrationApi);
33
+ const client = usePluginClient(IntegrationApi);
35
34
  const toast = useToast();
36
35
  const [searchParams, setSearchParams] = useSearchParams();
37
36
 
38
- const [subscriptions, setSubscriptions] = useState<WebhookSubscription[]>([]);
39
- const [providers, setProviders] = useState<IntegrationProviderInfo[]>([]);
40
- const [loading, setLoading] = useState(true);
41
37
  const [dialogOpen, setDialogOpen] = useState(false);
42
38
  const [selectedSubscription, setSelectedSubscription] =
43
39
  useState<WebhookSubscription>();
44
40
 
45
- // Stats state
46
- const [stats, setStats] = useState<{
47
- total: number;
48
- successful: number;
49
- failed: number;
50
- retrying: number;
51
- pending: number;
52
- }>();
41
+ // Queries using hooks
42
+ const {
43
+ data: subscriptionsData,
44
+ isLoading: subsLoading,
45
+ refetch: refetchSubs,
46
+ } = client.listSubscriptions.useQuery({ page: 1, pageSize: 100 });
53
47
 
54
- const fetchData = useCallback(async () => {
55
- try {
56
- const [subsResult, providersResult, statsResult] = await Promise.all([
57
- client.listSubscriptions({ page: 1, pageSize: 100 }),
58
- client.listProviders(),
59
- client.getDeliveryStats({ hours: 24 }),
60
- ]);
61
- setSubscriptions(subsResult.subscriptions);
62
- setProviders(providersResult);
63
- setStats(statsResult);
64
- } catch (error) {
65
- console.error("Failed to load integrations data:", error);
66
- toast.error("Failed to load integrations data");
67
- } finally {
68
- setLoading(false);
69
- }
70
- }, [client, toast]);
48
+ const { data: providers = [], isLoading: providersLoading } =
49
+ client.listProviders.useQuery({});
71
50
 
72
- useEffect(() => {
73
- void fetchData();
74
- }, [fetchData]);
51
+ const { data: stats, isLoading: statsLoading } =
52
+ client.getDeliveryStats.useQuery({ hours: 24 });
53
+
54
+ // Mutation for toggling
55
+ const toggleMutation = client.toggleSubscription.useMutation({
56
+ onSuccess: (_result, variables) => {
57
+ toast.success(
58
+ variables.enabled ? "Subscription enabled" : "Subscription disabled"
59
+ );
60
+ void refetchSubs();
61
+ },
62
+ onError: (error) => {
63
+ toast.error(
64
+ error instanceof Error ? error.message : "Failed to toggle subscription"
65
+ );
66
+ },
67
+ });
68
+
69
+ const subscriptions = subscriptionsData?.subscriptions ?? [];
70
+ const loading = subsLoading || providersLoading || statsLoading;
75
71
 
76
72
  // Handle ?action=create URL parameter (from command palette)
77
73
  useEffect(() => {
@@ -87,37 +83,29 @@ export const IntegrationsPage = () => {
87
83
  const getProviderInfo = (
88
84
  providerId: string
89
85
  ): IntegrationProviderInfo | undefined => {
90
- return providers.find((p) => p.qualifiedId === providerId);
86
+ return (providers as IntegrationProviderInfo[]).find(
87
+ (p) => p.qualifiedId === providerId
88
+ );
91
89
  };
92
90
 
93
- const handleToggle = async (id: string, enabled: boolean) => {
94
- try {
95
- await client.toggleSubscription({ id, enabled });
96
- setSubscriptions((prev) =>
97
- prev.map((s) => (s.id === id ? { ...s, enabled } : s))
98
- );
99
- toast.success(enabled ? "Subscription enabled" : "Subscription disabled");
100
- } catch (error) {
101
- console.error("Failed to toggle subscription:", error);
102
- toast.error("Failed to toggle subscription");
103
- }
91
+ const handleToggle = (id: string, enabled: boolean) => {
92
+ toggleMutation.mutate({ id, enabled });
104
93
  };
105
94
 
106
- const handleCreated = (newSub: WebhookSubscription) => {
107
- setSubscriptions((prev) => [newSub, ...prev]);
95
+ const handleCreated = () => {
96
+ void refetchSubs();
108
97
  setDialogOpen(false);
109
98
  setSelectedSubscription(undefined);
110
99
  };
111
100
 
112
101
  const handleUpdated = () => {
113
- // Refresh data after update
114
- void fetchData();
102
+ void refetchSubs();
115
103
  setDialogOpen(false);
116
104
  setSelectedSubscription(undefined);
117
105
  };
118
106
 
119
- const handleDeleted = (id: string) => {
120
- setSubscriptions((prev) => prev.filter((s) => s.id !== id));
107
+ const handleDeleted = () => {
108
+ void refetchSubs();
121
109
  setDialogOpen(false);
122
110
  setSelectedSubscription(undefined);
123
111
  };
@@ -277,7 +265,7 @@ export const IntegrationsPage = () => {
277
265
  size="sm"
278
266
  onClick={(e) => {
279
267
  e.stopPropagation();
280
- void handleToggle(sub.id, !sub.enabled);
268
+ handleToggle(sub.id, !sub.enabled);
281
269
  }}
282
270
  >
283
271
  {sub.enabled ? "Disable" : "Enable"}
@@ -308,7 +296,7 @@ export const IntegrationsPage = () => {
308
296
  description="Providers handle the delivery of events to external systems"
309
297
  />
310
298
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
311
- {providers.map((provider) => (
299
+ {(providers as IntegrationProviderInfo[]).map((provider) => (
312
300
  <Card key={provider.qualifiedId}>
313
301
  <CardContent className="p-4">
314
302
  <div className="flex items-center justify-between">
@@ -344,7 +332,7 @@ export const IntegrationsPage = () => {
344
332
  </CardContent>
345
333
  </Card>
346
334
  ))}
347
- {providers.length === 0 && (
335
+ {(providers as IntegrationProviderInfo[]).length === 0 && (
348
336
  <Card className="col-span-full">
349
337
  <CardContent className="p-4">
350
338
  <div className="text-center text-muted-foreground py-4">
@@ -364,7 +352,7 @@ export const IntegrationsPage = () => {
364
352
  setDialogOpen(open);
365
353
  if (!open) setSelectedSubscription(undefined);
366
354
  }}
367
- providers={providers}
355
+ providers={providers as IntegrationProviderInfo[]}
368
356
  subscription={selectedSubscription}
369
357
  onCreated={handleCreated}
370
358
  onUpdated={handleUpdated}
@@ -4,7 +4,7 @@
4
4
  * Manages site-wide connections for a specific integration provider.
5
5
  * Uses the provider's connectionSchema with DynamicForm for the configuration UI.
6
6
  */
7
- import { useState, useEffect, useCallback } from "react";
7
+ import { useState } from "react";
8
8
  import { useParams } from "react-router-dom";
9
9
  import {
10
10
  Plus,
@@ -44,7 +44,7 @@ import {
44
44
  BackLink,
45
45
  type LucideIconName,
46
46
  } from "@checkstack/ui";
47
- import { useApi, rpcApiRef } from "@checkstack/frontend-api";
47
+ import { usePluginClient } from "@checkstack/frontend-api";
48
48
  import { resolveRoute } from "@checkstack/common";
49
49
  import {
50
50
  IntegrationApi,
@@ -55,17 +55,9 @@ import {
55
55
 
56
56
  export const ProviderConnectionsPage = () => {
57
57
  const { providerId } = useParams<{ providerId: string }>();
58
- const rpcApi = useApi(rpcApiRef);
59
- const client = rpcApi.forPlugin(IntegrationApi);
60
- const toast = useToast();
61
58
 
62
- const [loading, setLoading] = useState(true);
63
- const [provider, setProvider] = useState<
64
- IntegrationProviderInfo | undefined
65
- >();
66
- const [connections, setConnections] = useState<ProviderConnectionRedacted[]>(
67
- []
68
- );
59
+ const client = usePluginClient(IntegrationApi);
60
+ const toast = useToast();
69
61
 
70
62
  // Dialog states
71
63
  const [createDialogOpen, setCreateDialogOpen] = useState(false);
@@ -89,128 +81,135 @@ export const ProviderConnectionsPage = () => {
89
81
  // Form validation state
90
82
  const [configValid, setConfigValid] = useState(false);
91
83
 
92
- const fetchData = useCallback(async () => {
93
- if (!providerId) return;
84
+ // Queries using hooks
85
+ const { data: providers = [], isLoading: providersLoading } =
86
+ client.listProviders.useQuery({});
94
87
 
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]);
88
+ const {
89
+ data: connections = [],
90
+ isLoading: connectionsLoading,
91
+ refetch: refetchConnections,
92
+ } = client.listConnections.useQuery(
93
+ { providerId: providerId ?? "" },
94
+ { enabled: !!providerId }
95
+ );
117
96
 
118
- const handleCreate = async () => {
119
- if (!providerId || !formName.trim()) return;
97
+ const loading = providersLoading || connectionsLoading;
98
+ const provider = (providers as IntegrationProviderInfo[]).find(
99
+ (p) => p.qualifiedId === providerId
100
+ );
120
101
 
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]);
102
+ // Mutations
103
+ const createMutation = client.createConnection.useMutation({
104
+ onSuccess: () => {
105
+ void refetchConnections();
129
106
  setCreateDialogOpen(false);
130
107
  setFormName("");
131
108
  setFormConfig({});
132
109
  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
110
  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))
111
+ },
112
+ onError: (error) => {
113
+ toast.error(
114
+ error instanceof Error ? error.message : "Failed to create connection"
163
115
  );
116
+ setSaving(false);
117
+ },
118
+ });
119
+
120
+ const updateMutation = client.updateConnection.useMutation({
121
+ onSuccess: () => {
122
+ void refetchConnections();
164
123
  setEditDialogOpen(false);
165
124
  setSelectedConnection(undefined);
166
125
  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
126
  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)
127
+ },
128
+ onError: (error) => {
129
+ toast.error(
130
+ error instanceof Error ? error.message : "Failed to update connection"
182
131
  );
132
+ setSaving(false);
133
+ },
134
+ });
135
+
136
+ const deleteMutation = client.deleteConnection.useMutation({
137
+ onSuccess: () => {
138
+ void refetchConnections();
183
139
  setDeleteConfirmOpen(false);
184
140
  setSelectedConnection(undefined);
185
141
  toast.success("Connection deleted");
186
- } catch (error) {
187
- console.error("Failed to delete connection:", error);
188
- toast.error("Failed to delete connection");
189
- }
190
- };
142
+ },
143
+ onError: (error) => {
144
+ toast.error(
145
+ error instanceof Error ? error.message : "Failed to delete connection"
146
+ );
147
+ },
148
+ });
191
149
 
192
- const handleTest = async (connectionId: string) => {
193
- setTestingId(connectionId);
194
- try {
195
- const result = await client.testConnection({ connectionId });
150
+ const testMutation = client.testConnection.useMutation({
151
+ onSuccess: (result, variables) => {
196
152
  setTestResults((prev) => ({
197
153
  ...prev,
198
- [connectionId]: result,
154
+ [variables.connectionId]: result,
199
155
  }));
200
156
  if (result.success) {
201
157
  toast.success(result.message ?? "Connection test successful");
202
158
  } else {
203
159
  toast.error(result.message ?? "Connection test failed");
204
160
  }
205
- } catch {
161
+ setTestingId(undefined);
162
+ },
163
+ onError: (error, variables) => {
206
164
  setTestResults((prev) => ({
207
165
  ...prev,
208
- [connectionId]: { success: false, message: "Test failed" },
166
+ [variables.connectionId]: { success: false, message: "Test failed" },
209
167
  }));
210
- toast.error("Connection test failed");
211
- } finally {
168
+ toast.error(
169
+ error instanceof Error ? error.message : "Connection test failed"
170
+ );
212
171
  setTestingId(undefined);
213
- }
172
+ },
173
+ });
174
+
175
+ const handleCreate = () => {
176
+ if (!providerId || !formName.trim()) return;
177
+ setSaving(true);
178
+ createMutation.mutate({
179
+ providerId,
180
+ name: formName.trim(),
181
+ config: formConfig,
182
+ });
183
+ };
184
+
185
+ // Reset form when creating
186
+ const openCreateDialog = () => {
187
+ setFormName("");
188
+ setFormConfig({});
189
+ setConfigValid(false);
190
+ setCreateDialogOpen(true);
191
+ };
192
+
193
+ const handleUpdate = () => {
194
+ if (!selectedConnection) return;
195
+ setSaving(true);
196
+ updateMutation.mutate({
197
+ connectionId: selectedConnection.id,
198
+ updates: {
199
+ name: formName.trim() || selectedConnection.name,
200
+ config: formConfig,
201
+ },
202
+ });
203
+ };
204
+
205
+ const handleDelete = () => {
206
+ if (!selectedConnection) return;
207
+ deleteMutation.mutate({ connectionId: selectedConnection.id });
208
+ };
209
+
210
+ const handleTest = (connectionId: string) => {
211
+ setTestingId(connectionId);
212
+ testMutation.mutate({ connectionId });
214
213
  };
215
214
 
216
215
  const openEditDialog = (connection: ProviderConnectionRedacted) => {
@@ -271,7 +270,7 @@ export const ProviderConnectionsPage = () => {
271
270
  </div>
272
271
  }
273
272
  >
274
- {connections.length === 0 ? (
273
+ {(connections as ProviderConnectionRedacted[]).length === 0 ? (
275
274
  <EmptyState
276
275
  icon={
277
276
  <DynamicIcon
@@ -309,7 +308,7 @@ export const ProviderConnectionsPage = () => {
309
308
  </TableRow>
310
309
  </TableHeader>
311
310
  <TableBody>
312
- {connections.map((conn) => {
311
+ {(connections as ProviderConnectionRedacted[]).map((conn) => {
313
312
  const testResult = testResults[conn.id];
314
313
  const isTesting = testingId === conn.id;
315
314