@dev-fastn-ai/react-core 1.0.20 → 2.0.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/README.md +814 -211
- package/dist/core/src/core/activate-connector.d.ts +2 -4
- package/dist/core/src/core/configuration-form.d.ts +1 -1
- package/dist/core/src/types/index.d.ts +12 -11
- package/dist/core/src/utils/errors.d.ts +3 -3
- package/dist/index.cjs.js +720 -34
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +10 -9
- package/dist/index.esm.js +717 -29
- package/dist/index.esm.js.map +1 -1
- package/dist/react-core/src/index.d.ts +2 -1
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -1,75 +1,141 @@
|
|
|
1
|
-
#
|
|
1
|
+
# [Fastn.ai](http://fastn.ai/) React Core Documentation
|
|
2
2
|
|
|
3
|
-
A React library for integrating Fastn AI connectors into your application.
|
|
3
|
+
A React library for integrating **Fastn AI connectors** into your application. It provides powerful hooks to manage:
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
- Connector listing, activation, and deactivation
|
|
6
|
+
- Configuration form rendering and submission
|
|
7
|
+
- Seamless integration with React Query for state management
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
- Handle connector configurations
|
|
11
|
-
- Render and submit dynamic configuration forms
|
|
12
|
-
- Powered by React Query (supports custom or existing clients)
|
|
9
|
+
This enables you to **build fully custom UIs** on top of Fastn's powerful data and logic engine.
|
|
13
10
|
|
|
14
11
|
---
|
|
15
12
|
|
|
16
|
-
## Installation
|
|
13
|
+
## 📦 Installation
|
|
14
|
+
|
|
15
|
+
Install the core library:
|
|
17
16
|
|
|
18
17
|
```bash
|
|
19
|
-
npm install @fastn-ai/react-core
|
|
18
|
+
npm install @dev-fastn-ai/react-core
|
|
20
19
|
```
|
|
21
20
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
Ensure you have React 18+ and React Query installed:
|
|
21
|
+
Also, make sure you install the required **peer dependencies**:
|
|
25
22
|
|
|
26
23
|
```bash
|
|
27
24
|
npm install react react-dom @tanstack/react-query
|
|
28
25
|
```
|
|
29
26
|
|
|
27
|
+
> ✅ Requires React 18+
|
|
28
|
+
|
|
30
29
|
---
|
|
31
30
|
|
|
32
|
-
##
|
|
31
|
+
## 🏗️ Fastn Architecture Concepts
|
|
32
|
+
|
|
33
|
+
Before diving into the code, let's understand the key Fastn concepts and terminology:
|
|
34
|
+
|
|
35
|
+
### **Space (Workspace)**
|
|
36
|
+
|
|
37
|
+
A **Space** (also called Workspace) is the top-level container in Fastn that groups all your connectors, configurations, and data flows. Think of it as your project or organization's workspace where all integrations live.
|
|
38
|
+
|
|
39
|
+
### **Tenant**
|
|
40
|
+
|
|
41
|
+
A **Tenant** represents a user, team, or organization within your application. Each tenant has isolated data and configurations. For example:
|
|
42
|
+
|
|
43
|
+
- A single user account
|
|
44
|
+
- A team within your app
|
|
45
|
+
- An organization or company
|
|
46
|
+
- A client's workspace
|
|
47
|
+
|
|
48
|
+
### **Connector**
|
|
49
|
+
|
|
50
|
+
A **Connector** represents an integration with an external service (like Slack, Google Drive, etc.). Connectors define what external services your app can connect to.
|
|
51
|
+
|
|
52
|
+
### **Configuration**
|
|
53
|
+
|
|
54
|
+
A **Configuration** is a specific instance of a connector with saved settings and authentication. For example:
|
|
55
|
+
|
|
56
|
+
- A Slack workspace connection with specific channels selected
|
|
57
|
+
- A Google Drive connection with specific folders configured
|
|
58
|
+
- A database connection with connection parameters
|
|
33
59
|
|
|
34
|
-
###
|
|
60
|
+
### **Configuration ID**
|
|
35
61
|
|
|
36
|
-
|
|
62
|
+
A **Configuration ID** is a unique identifier that represents a specific configuration instance. This ID is used to:
|
|
63
|
+
|
|
64
|
+
- Load existing configurations
|
|
65
|
+
- Update configuration settings
|
|
66
|
+
- Manage the lifecycle of a specific integration
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## ⚙️ Features
|
|
71
|
+
|
|
72
|
+
- **Connector Management**: List, activate, and deactivate connectors
|
|
73
|
+
- **Tenant Isolation**: Each tenant has its own isolated connector state and configurations
|
|
74
|
+
- **Configuration Persistence**: Save and retrieve configurations using unique `configurationId`s
|
|
75
|
+
- **Dynamic Forms**: Render configuration forms using Fastn's form schema
|
|
76
|
+
- **React Query Integration**: Built-in support for efficient caching and request handling
|
|
77
|
+
- **Authentication Flow**: Handle OAuth and custom authentication flows seamlessly
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 🚀 Getting Started
|
|
82
|
+
|
|
83
|
+
### 1. **Wrap Your App with `FastnProvider`**
|
|
84
|
+
|
|
85
|
+
This sets up Fastn in your app and gives you access to its hooks and logic.
|
|
37
86
|
|
|
38
87
|
```tsx
|
|
39
|
-
import { FastnProvider } from "@fastn-ai/react-core";
|
|
88
|
+
import { FastnProvider } from "@dev-fastn-ai/react-core";
|
|
40
89
|
|
|
41
90
|
const fastnConfig = {
|
|
42
|
-
environment: "LIVE", //
|
|
43
|
-
authToken: "your-auth-token",
|
|
44
|
-
tenantId: "your-tenant-id",
|
|
45
|
-
spaceId: "your-space-id",
|
|
91
|
+
environment: "LIVE", // "LIVE", "DRAFT", or a custom environment string for widgets
|
|
92
|
+
authToken: "your-auth-token", // Your app's access token, authenticated through Fastn Custom Auth
|
|
93
|
+
tenantId: "your-tenant-id", // A unique ID representing the user, team, or organization
|
|
94
|
+
spaceId: "your-space-id", // Fastn Space ID (also called Workspace ID) - groups all connectors and configurations
|
|
46
95
|
};
|
|
47
96
|
|
|
48
97
|
function App() {
|
|
49
98
|
return (
|
|
50
99
|
<FastnProvider config={fastnConfig}>
|
|
51
|
-
{/* Your app components
|
|
100
|
+
{/* Your app components */}
|
|
52
101
|
</FastnProvider>
|
|
53
102
|
);
|
|
54
103
|
}
|
|
55
104
|
```
|
|
56
105
|
|
|
57
|
-
###
|
|
106
|
+
### 🔍 Configuration Field Reference
|
|
107
|
+
|
|
108
|
+
| Field | Description |
|
|
109
|
+
| ------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
110
|
+
| `environment` | The widget environment: use `"LIVE"` for production, `"DRAFT"` for preview/testing, or any custom string configured in Fastn |
|
|
111
|
+
| `authToken` | The token from your authentication flow. Fastn uses this to authenticate the user via the **fastnCustomAuth** flow |
|
|
112
|
+
| `tenantId` | A unique identifier for the current user or organization. This helps Fastn isolate data per tenant |
|
|
113
|
+
| `spaceId` | The Fastn **Space ID**, also called the Workspace ID. It groups all connectors, configurations, and flows |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### 2. **Use an Existing React Query Client (Optional)**
|
|
58
118
|
|
|
59
119
|
If your app already uses React Query, you can pass your own client:
|
|
60
120
|
|
|
61
121
|
```tsx
|
|
62
|
-
import { QueryClient, QueryClientProvider } from
|
|
63
|
-
import { FastnProvider } from "@fastn-ai/react-core";
|
|
122
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
123
|
+
import { FastnProvider } from "@dev-fastn-ai/react-core";
|
|
64
124
|
|
|
65
125
|
const queryClient = new QueryClient();
|
|
66
|
-
|
|
126
|
+
|
|
127
|
+
const fastnConfig = {
|
|
128
|
+
environment: "LIVE",
|
|
129
|
+
authToken: "your-auth-token",
|
|
130
|
+
tenantId: "your-tenant-id",
|
|
131
|
+
spaceId: "your-space-id",
|
|
132
|
+
};
|
|
67
133
|
|
|
68
134
|
function App() {
|
|
69
135
|
return (
|
|
70
136
|
<QueryClientProvider client={queryClient}>
|
|
71
137
|
<FastnProvider config={fastnConfig}>
|
|
72
|
-
{/* Your app components
|
|
138
|
+
{/* Your app components */}
|
|
73
139
|
</FastnProvider>
|
|
74
140
|
</QueryClientProvider>
|
|
75
141
|
);
|
|
@@ -78,34 +144,11 @@ function App() {
|
|
|
78
144
|
|
|
79
145
|
---
|
|
80
146
|
|
|
81
|
-
##
|
|
147
|
+
## 🧩 Core Hooks & Types
|
|
82
148
|
|
|
83
|
-
###
|
|
84
|
-
|
|
85
|
-
Use the `useConnectors` hook to retrieve the list of available connectors.
|
|
149
|
+
### **Connector Types**
|
|
86
150
|
|
|
87
151
|
```tsx
|
|
88
|
-
import { useConnectors } from "@fastn-ai/react-core";
|
|
89
|
-
|
|
90
|
-
function ConnectorsList() {
|
|
91
|
-
const { data: connectors, isLoading, error } = useConnectors();
|
|
92
|
-
|
|
93
|
-
if (isLoading) return <div>Loading...</div>;
|
|
94
|
-
if (error) return <div>Error: {error.message}</div>;
|
|
95
|
-
|
|
96
|
-
return (
|
|
97
|
-
<ul>
|
|
98
|
-
{connectors?.map((connector) => (
|
|
99
|
-
<li key={connector.id}>{connector.name}</li>
|
|
100
|
-
))}
|
|
101
|
-
</ul>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
#### Types
|
|
107
|
-
|
|
108
|
-
```ts
|
|
109
152
|
interface Connector {
|
|
110
153
|
id: string;
|
|
111
154
|
name: string;
|
|
@@ -124,13 +167,10 @@ enum ConnectorStatus {
|
|
|
124
167
|
interface ConnectorAction {
|
|
125
168
|
name: string;
|
|
126
169
|
actionType: ConnectorActionType | string;
|
|
127
|
-
|
|
128
|
-
onClick?: (data?: unknown) => Promise<ConnectorActionResult>;
|
|
129
|
-
onSubmit?: (formData: Record<string, unknown>) => Promise<ConnectorActionResult>;
|
|
170
|
+
onClick?: () => Promise<ConnectorActionResult>;
|
|
130
171
|
}
|
|
131
172
|
|
|
132
173
|
interface ConnectorActionResult {
|
|
133
|
-
data: unknown;
|
|
134
174
|
status: "SUCCESS" | "ERROR" | "CANCELLED";
|
|
135
175
|
}
|
|
136
176
|
|
|
@@ -141,123 +181,439 @@ enum ConnectorActionType {
|
|
|
141
181
|
}
|
|
142
182
|
```
|
|
143
183
|
|
|
184
|
+
### **Configuration Types**
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
interface Configuration {
|
|
188
|
+
id: string;
|
|
189
|
+
connectorId: string;
|
|
190
|
+
name: string;
|
|
191
|
+
description: string;
|
|
192
|
+
imageUri?: string;
|
|
193
|
+
status: ConfigurationStatus;
|
|
194
|
+
actions: ConfigurationAction[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
enum ConfigurationStatus {
|
|
198
|
+
ENABLED = "ENABLED",
|
|
199
|
+
DISABLED = "DISABLED",
|
|
200
|
+
PENDING = "PENDING",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
interface ConfigurationAction {
|
|
204
|
+
name: string;
|
|
205
|
+
actionType: ConfigurationActionType | string;
|
|
206
|
+
onClick?: () => Promise<ConfigurationActionResult>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface ConfigurationActionResult {
|
|
210
|
+
status: "SUCCESS" | "ERROR" | "CANCELLED";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
enum ConfigurationActionType {
|
|
214
|
+
ENABLE = "ENABLE",
|
|
215
|
+
DISABLE = "DISABLE",
|
|
216
|
+
DELETE = "DELETE",
|
|
217
|
+
UPDATE = "UPDATE",
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### **Configuration Form Types**
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
interface ConfigurationForm {
|
|
225
|
+
name: string;
|
|
226
|
+
description: string;
|
|
227
|
+
imageUri: string;
|
|
228
|
+
fields: ConnectorField[];
|
|
229
|
+
submitHandler: (formData: FormData) => Promise<void>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type FormData =
|
|
233
|
+
| Record<
|
|
234
|
+
string,
|
|
235
|
+
Record<string, Primitive> | Record<string, Primitive>[] | undefined | null
|
|
236
|
+
>
|
|
237
|
+
| Record<
|
|
238
|
+
string,
|
|
239
|
+
Record<string, Primitive> | Record<string, Primitive>[] | undefined | null
|
|
240
|
+
>[]
|
|
241
|
+
| undefined
|
|
242
|
+
| null;
|
|
243
|
+
|
|
244
|
+
interface ConnectorField {
|
|
245
|
+
readonly name: string;
|
|
246
|
+
readonly key: string;
|
|
247
|
+
readonly label: string;
|
|
248
|
+
readonly type: ConnectorFieldType | string;
|
|
249
|
+
readonly required: boolean;
|
|
250
|
+
readonly placeholder: string;
|
|
251
|
+
readonly description: string;
|
|
252
|
+
readonly hidden?: boolean;
|
|
253
|
+
readonly disabled?: boolean;
|
|
254
|
+
readonly initialValue?:
|
|
255
|
+
| Record<string, Primitive>
|
|
256
|
+
| Record<string, Primitive>[]
|
|
257
|
+
| Primitive
|
|
258
|
+
| Primitive[];
|
|
259
|
+
readonly optionsSource?: SelectOptionSource;
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
144
263
|
---
|
|
145
264
|
|
|
146
|
-
|
|
265
|
+
## 🔄 Complete Integration Workflows
|
|
266
|
+
|
|
267
|
+
### **Workflow 1: Setting Up Your First Slack Integration**
|
|
147
268
|
|
|
148
|
-
|
|
269
|
+
Let's walk through a complete example of setting up a Slack integration from scratch.
|
|
270
|
+
|
|
271
|
+
#### Step 1: List Available Connectors
|
|
272
|
+
|
|
273
|
+
First, show users what connectors are available:
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
import { useConnectors } from "@dev-fastn-ai/react-core";
|
|
277
|
+
|
|
278
|
+
function ConnectorList() {
|
|
279
|
+
const { data: connectors, isLoading, error } = useConnectors();
|
|
280
|
+
|
|
281
|
+
if (isLoading) return <div>Loading available integrations...</div>;
|
|
282
|
+
if (error) return <div>Error loading connectors: {error.message}</div>;
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div className="connector-grid">
|
|
286
|
+
<h2>Available Integrations</h2>
|
|
287
|
+
{connectors?.map((connector) => (
|
|
288
|
+
<div key={connector.id} className="connector-card">
|
|
289
|
+
<img src={connector.imageUri} alt={connector.name} />
|
|
290
|
+
<h3>{connector.name}</h3>
|
|
291
|
+
<p>{connector.description}</p>
|
|
292
|
+
|
|
293
|
+
{connector.status === "ACTIVE" && (
|
|
294
|
+
<span className="status-badge connected">Connected</span>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{connector.actions?.map((action) => (
|
|
298
|
+
<button
|
|
299
|
+
key={action.name}
|
|
300
|
+
onClick={action.onClick}
|
|
301
|
+
className={`action-btn ${action.actionType.toLowerCase()}`}
|
|
302
|
+
>
|
|
303
|
+
{action.name}
|
|
304
|
+
</button>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
))}
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### Step 2: List Configurations After Connector Activation
|
|
314
|
+
|
|
315
|
+
After a connector is activated, you can list its configurations:
|
|
149
316
|
|
|
150
317
|
```tsx
|
|
151
|
-
import { useConfigurations } from "@fastn-ai/react-core";
|
|
318
|
+
import { useConfigurations } from "@dev-fastn-ai/react-core";
|
|
152
319
|
|
|
153
|
-
function
|
|
154
|
-
const {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
320
|
+
function ConfigurationList({ configurationId }) {
|
|
321
|
+
const {
|
|
322
|
+
data: configurations,
|
|
323
|
+
isLoading,
|
|
324
|
+
error,
|
|
325
|
+
} = useConfigurations({ configurationId });
|
|
326
|
+
const [selectedConfig, setSelectedConfig] = useState(null);
|
|
158
327
|
|
|
159
|
-
if (isLoading) return <div>Loading...</div>;
|
|
328
|
+
if (isLoading) return <div>Loading configurations...</div>;
|
|
160
329
|
if (error) return <div>Error: {error.message}</div>;
|
|
161
330
|
|
|
162
331
|
return (
|
|
163
|
-
<
|
|
332
|
+
<div className="configuration-list">
|
|
333
|
+
<h2>Your Integrations</h2>
|
|
164
334
|
{configurations?.map((config) => (
|
|
165
|
-
<
|
|
335
|
+
<div key={config.id} className="config-card">
|
|
336
|
+
<div className="config-info">
|
|
337
|
+
<img src={config.imageUri} alt={config.name} />
|
|
338
|
+
<div>
|
|
339
|
+
<h3>{config.name}</h3>
|
|
340
|
+
<p>{config.description}</p>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<div className="config-actions">
|
|
345
|
+
{config.status === "ENABLED" && (
|
|
346
|
+
<span className="status-badge enabled">Active</span>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
{config.actions?.map((action) => (
|
|
350
|
+
<button
|
|
351
|
+
key={action.name}
|
|
352
|
+
onClick={async () => {
|
|
353
|
+
const result = await action.onClick();
|
|
354
|
+
if (
|
|
355
|
+
action.actionType === "ENABLE" &&
|
|
356
|
+
result?.status === "SUCCESS"
|
|
357
|
+
) {
|
|
358
|
+
// Show configuration form for new setup
|
|
359
|
+
setSelectedConfig(config);
|
|
360
|
+
} else if (
|
|
361
|
+
action.actionType === "UPDATE" &&
|
|
362
|
+
result?.status === "SUCCESS"
|
|
363
|
+
) {
|
|
364
|
+
// Show configuration form for editing
|
|
365
|
+
setSelectedConfig(config);
|
|
366
|
+
}
|
|
367
|
+
}}
|
|
368
|
+
className={`action-btn ${action.actionType.toLowerCase()}`}
|
|
369
|
+
>
|
|
370
|
+
{action.name}
|
|
371
|
+
</button>
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
166
375
|
))}
|
|
167
|
-
|
|
376
|
+
|
|
377
|
+
{selectedConfig && (
|
|
378
|
+
<ConfigurationForm
|
|
379
|
+
configurationId={selectedConfig.id}
|
|
380
|
+
onClose={() => setSelectedConfig(null)}
|
|
381
|
+
/>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
168
384
|
);
|
|
169
385
|
}
|
|
170
386
|
```
|
|
171
387
|
|
|
172
|
-
####
|
|
388
|
+
#### Step 3: Load Configuration Form
|
|
173
389
|
|
|
174
|
-
|
|
175
|
-
interface Configuration {
|
|
176
|
-
id: string;
|
|
177
|
-
connectorId: string;
|
|
178
|
-
configurationId: string;
|
|
179
|
-
name: string;
|
|
180
|
-
flowId: string;
|
|
181
|
-
description: string;
|
|
182
|
-
imageUri: string;
|
|
183
|
-
status: string;
|
|
184
|
-
actions: ConfigurationAction[];
|
|
185
|
-
metadata?: unknown;
|
|
186
|
-
}
|
|
390
|
+
When a configuration is selected (either for new setup or editing), show the configuration form:
|
|
187
391
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
actionType: ConfigurationActionType;
|
|
191
|
-
onClick?: () => Promise<ConnectorActionResult>;
|
|
192
|
-
form?: ConnectorForm | null;
|
|
193
|
-
onSubmit?: (formData: unknown) => Promise<ConnectorActionResult>;
|
|
194
|
-
}
|
|
392
|
+
```tsx
|
|
393
|
+
import { useConfigurationForm } from "@dev-fastn-ai/react-core";
|
|
195
394
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
395
|
+
function ConfigurationForm({ configurationId, onClose }) {
|
|
396
|
+
const {
|
|
397
|
+
data: configurationForm,
|
|
398
|
+
isLoading,
|
|
399
|
+
error,
|
|
400
|
+
handleSubmit,
|
|
401
|
+
} = useConfigurationForm({ configurationId });
|
|
402
|
+
|
|
403
|
+
const [formData, setFormData] = useState({});
|
|
404
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
405
|
+
|
|
406
|
+
// Pre-populate form with existing values if editing
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
if (configurationForm?.fields) {
|
|
409
|
+
const initialData = {};
|
|
410
|
+
configurationForm.fields.forEach((field) => {
|
|
411
|
+
if (field.initialValue !== undefined) {
|
|
412
|
+
initialData[field.key] = field.initialValue;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
setFormData(initialData);
|
|
416
|
+
}
|
|
417
|
+
}, [configurationForm]);
|
|
418
|
+
|
|
419
|
+
if (isLoading) return <div>Loading configuration form...</div>;
|
|
420
|
+
if (error) return <div>Error: {error.message}</div>;
|
|
421
|
+
|
|
422
|
+
const onSubmit = async (e) => {
|
|
423
|
+
e.preventDefault();
|
|
424
|
+
setIsSubmitting(true);
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
await handleSubmit({ formData });
|
|
428
|
+
console.log("Configuration saved successfully!");
|
|
429
|
+
onClose();
|
|
430
|
+
} catch (error) {
|
|
431
|
+
console.error("Failed to save configuration:", error);
|
|
432
|
+
} finally {
|
|
433
|
+
setIsSubmitting(false);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
return (
|
|
438
|
+
<div className="modal">
|
|
439
|
+
<form onSubmit={onSubmit} className="configuration-form">
|
|
440
|
+
<h2>Configure {configurationForm.name}</h2>
|
|
441
|
+
<p>{configurationForm.description}</p>
|
|
442
|
+
|
|
443
|
+
{configurationForm.fields.map((field) => (
|
|
444
|
+
<FormField
|
|
445
|
+
key={field.key}
|
|
446
|
+
field={field}
|
|
447
|
+
value={formData[field.key]}
|
|
448
|
+
onChange={(value) =>
|
|
449
|
+
setFormData((prev) => ({ ...prev, [field.key]: value }))
|
|
450
|
+
}
|
|
451
|
+
/>
|
|
452
|
+
))}
|
|
453
|
+
|
|
454
|
+
<div className="form-actions">
|
|
455
|
+
<button type="button" onClick={onClose}>
|
|
456
|
+
Cancel
|
|
457
|
+
</button>
|
|
458
|
+
<button type="submit" disabled={isSubmitting}>
|
|
459
|
+
{isSubmitting ? "Saving..." : "Save Configuration"}
|
|
460
|
+
</button>
|
|
461
|
+
</div>
|
|
462
|
+
</form>
|
|
463
|
+
</div>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
200
466
|
```
|
|
201
467
|
|
|
202
|
-
|
|
468
|
+
### **Workflow 2: Managing Existing Configurations**
|
|
203
469
|
|
|
204
|
-
|
|
470
|
+
Now let's show how to manage existing configurations - viewing, editing, and disabling them.
|
|
205
471
|
|
|
206
|
-
|
|
472
|
+
#### Step 1: List Existing Configurations
|
|
207
473
|
|
|
208
474
|
```tsx
|
|
209
|
-
import {
|
|
475
|
+
import { useConfigurations } from "@dev-fastn-ai/react-core";
|
|
210
476
|
|
|
211
|
-
function
|
|
212
|
-
const {
|
|
477
|
+
function ConfigurationManager({ configurationId }) {
|
|
478
|
+
const {
|
|
479
|
+
data: configurations,
|
|
480
|
+
isLoading,
|
|
481
|
+
error,
|
|
482
|
+
} = useConfigurations({ configurationId });
|
|
483
|
+
const [selectedConfig, setSelectedConfig] = useState(null);
|
|
213
484
|
|
|
214
|
-
if (isLoading) return <div>Loading...</div>;
|
|
485
|
+
if (isLoading) return <div>Loading your integrations...</div>;
|
|
215
486
|
if (error) return <div>Error: {error.message}</div>;
|
|
216
487
|
|
|
217
488
|
return (
|
|
218
|
-
<
|
|
219
|
-
|
|
220
|
-
|
|
489
|
+
<div className="configuration-manager">
|
|
490
|
+
<h2>Your Integrations</h2>
|
|
491
|
+
|
|
492
|
+
{configurations?.map((config) => (
|
|
493
|
+
<div key={config.id} className="config-card">
|
|
494
|
+
<div className="config-info">
|
|
495
|
+
<img src={config.imageUri} alt={config.name} />
|
|
496
|
+
<div>
|
|
497
|
+
<h3>{config.name}</h3>
|
|
498
|
+
<p>{config.description}</p>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<div className="config-actions">
|
|
503
|
+
{config.status === "ENABLED" && (
|
|
504
|
+
<span className="status-badge enabled">Active</span>
|
|
505
|
+
)}
|
|
506
|
+
|
|
507
|
+
{config.actions?.map((action) => (
|
|
508
|
+
<button
|
|
509
|
+
key={action.name}
|
|
510
|
+
onClick={async () => {
|
|
511
|
+
const result = await action.onClick();
|
|
512
|
+
if (
|
|
513
|
+
action.actionType === "UPDATE" &&
|
|
514
|
+
result?.status === "SUCCESS"
|
|
515
|
+
) {
|
|
516
|
+
setSelectedConfig(config);
|
|
517
|
+
}
|
|
518
|
+
}}
|
|
519
|
+
className={`action-btn ${action.actionType.toLowerCase()}`}
|
|
520
|
+
>
|
|
521
|
+
{action.name}
|
|
522
|
+
</button>
|
|
523
|
+
))}
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
221
526
|
))}
|
|
222
|
-
|
|
223
|
-
|
|
527
|
+
|
|
528
|
+
{selectedConfig && (
|
|
529
|
+
<ConfigurationForm
|
|
530
|
+
configurationId={selectedConfig.id}
|
|
531
|
+
onClose={() => setSelectedConfig(null)}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
224
535
|
);
|
|
225
536
|
}
|
|
226
537
|
```
|
|
227
538
|
|
|
228
|
-
####
|
|
539
|
+
#### Step 2: Disable Configuration
|
|
229
540
|
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
541
|
+
```tsx
|
|
542
|
+
function ConfigActions({ config }) {
|
|
543
|
+
const handleDisable = async (action) => {
|
|
544
|
+
if (action.actionType === "DISABLE") {
|
|
545
|
+
const result = await action.onClick();
|
|
546
|
+
if (result?.status === "SUCCESS") {
|
|
547
|
+
console.log("Configuration disabled successfully");
|
|
548
|
+
// Refresh the configuration list
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
};
|
|
238
552
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
553
|
+
return (
|
|
554
|
+
<div className="config-actions">
|
|
555
|
+
{config.actions?.map((action) => (
|
|
556
|
+
<button
|
|
557
|
+
key={action.name}
|
|
558
|
+
onClick={() => handleDisable(action)}
|
|
559
|
+
className={`action-btn ${action.actionType.toLowerCase()}`}
|
|
560
|
+
>
|
|
561
|
+
{action.name}
|
|
562
|
+
</button>
|
|
563
|
+
))}
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
246
566
|
}
|
|
247
567
|
```
|
|
248
568
|
|
|
249
|
-
|
|
569
|
+
#### Step 3: Form Field Value Handling
|
|
570
|
+
|
|
571
|
+
The form fields handle different value types based on the field type:
|
|
250
572
|
|
|
251
|
-
|
|
573
|
+
- **Select fields**: Always return `{ label: string, value: string }` objects
|
|
574
|
+
- **Multi-select fields**: Always return `{ label: string, value: string }[]` arrays
|
|
575
|
+
- **Google Drive picker fields**: Always return `{ label: string, value: string }` objects or arrays
|
|
576
|
+
- **Other fields**: Return primitive values (string, number, boolean)
|
|
252
577
|
|
|
253
|
-
|
|
578
|
+
```tsx
|
|
579
|
+
// Example form data structure
|
|
580
|
+
const formData = {
|
|
581
|
+
// Select field - single object
|
|
582
|
+
channel: { label: "General", value: "C123456" },
|
|
583
|
+
|
|
584
|
+
// Multi-select field - array of objects
|
|
585
|
+
channels: [
|
|
586
|
+
{ label: "General", value: "C123456" },
|
|
587
|
+
{ label: "Random", value: "C789012" },
|
|
588
|
+
],
|
|
589
|
+
|
|
590
|
+
// Google Drive picker - single object
|
|
591
|
+
folder: { label: "My Documents", value: "folder_id_123" },
|
|
592
|
+
|
|
593
|
+
// Google Drive picker multi - array of objects
|
|
594
|
+
files: [
|
|
595
|
+
{ label: "document1.pdf", value: "file_id_1" },
|
|
596
|
+
{ label: "document2.pdf", value: "file_id_2" },
|
|
597
|
+
],
|
|
598
|
+
|
|
599
|
+
// Text field - primitive
|
|
600
|
+
webhookUrl: "https://hooks.slack.com/...",
|
|
601
|
+
|
|
602
|
+
// Boolean field - primitive
|
|
603
|
+
enableNotifications: true,
|
|
604
|
+
};
|
|
605
|
+
```
|
|
254
606
|
|
|
255
|
-
|
|
607
|
+
---
|
|
256
608
|
|
|
257
|
-
|
|
609
|
+
## 🎨 Form Field Components
|
|
610
|
+
|
|
611
|
+
### **Select and Multi-Select Fields**
|
|
612
|
+
|
|
613
|
+
For fields of type `select` or `multi-select`, use the `useFieldOptions` hook to handle dynamic options loading. These fields always work with `{ label, value }` objects:
|
|
258
614
|
|
|
259
615
|
```tsx
|
|
260
|
-
import { useFieldOptions } from "@fastn-ai/react-core";
|
|
616
|
+
import { useFieldOptions } from "@dev-fastn-ai/react-core";
|
|
261
617
|
|
|
262
618
|
function SelectField({ field, value, onChange, isMulti = false }) {
|
|
263
619
|
const {
|
|
@@ -268,7 +624,6 @@ function SelectField({ field, value, onChange, isMulti = false }) {
|
|
|
268
624
|
loadMore,
|
|
269
625
|
error,
|
|
270
626
|
search,
|
|
271
|
-
refresh,
|
|
272
627
|
totalLoadedOptions,
|
|
273
628
|
} = useFieldOptions(field);
|
|
274
629
|
|
|
@@ -280,75 +635,105 @@ function SelectField({ field, value, onChange, isMulti = false }) {
|
|
|
280
635
|
if (hasNext && !loadingMore) loadMore();
|
|
281
636
|
}
|
|
282
637
|
|
|
283
|
-
function
|
|
284
|
-
|
|
638
|
+
function handleSelectChange(selectedOptions) {
|
|
639
|
+
if (isMulti) {
|
|
640
|
+
// For multi-select, value should be an array of { label, value } objects
|
|
641
|
+
const selectedValues = selectedOptions.map((option) => ({
|
|
642
|
+
label: option.label,
|
|
643
|
+
value: option.value,
|
|
644
|
+
}));
|
|
645
|
+
onChange(selectedValues);
|
|
646
|
+
} else {
|
|
647
|
+
// For single select, value should be a single { label, value } object
|
|
648
|
+
const selectedValue = selectedOptions[0]
|
|
649
|
+
? {
|
|
650
|
+
label: selectedOptions[0].label,
|
|
651
|
+
value: selectedOptions[0].value,
|
|
652
|
+
}
|
|
653
|
+
: null;
|
|
654
|
+
onChange(selectedValue);
|
|
655
|
+
}
|
|
285
656
|
}
|
|
286
657
|
|
|
287
658
|
return (
|
|
288
|
-
<div>
|
|
289
|
-
<label
|
|
659
|
+
<div className="field-container">
|
|
660
|
+
<label className="field-label">
|
|
661
|
+
{field.label}
|
|
662
|
+
{field.required && <span className="required"> *</span>}
|
|
663
|
+
</label>
|
|
664
|
+
|
|
290
665
|
{error && (
|
|
291
|
-
<div>
|
|
292
|
-
|
|
293
|
-
<button type="button" onClick={handleRefresh}>Retry</button>
|
|
666
|
+
<div className="error-message">
|
|
667
|
+
Error loading options: {error.message}
|
|
294
668
|
</div>
|
|
295
669
|
)}
|
|
670
|
+
|
|
296
671
|
<input
|
|
297
672
|
type="text"
|
|
298
673
|
placeholder={field.placeholder || `Search ${field.label}`}
|
|
299
674
|
onChange={handleInputChange}
|
|
300
675
|
disabled={loading}
|
|
676
|
+
className="search-input"
|
|
301
677
|
/>
|
|
678
|
+
|
|
302
679
|
<select
|
|
303
680
|
multiple={isMulti}
|
|
304
|
-
value={value}
|
|
305
|
-
onChange={e => {
|
|
681
|
+
value={isMulti ? (value || []).map((v) => v.value) : value?.value || ""}
|
|
682
|
+
onChange={(e) => {
|
|
306
683
|
if (isMulti) {
|
|
307
|
-
const
|
|
308
|
-
|
|
684
|
+
const selectedOptions = Array.from(e.target.selectedOptions).map(
|
|
685
|
+
(option) => {
|
|
686
|
+
const opt = options.find((o) => o.value === option.value);
|
|
687
|
+
return { label: opt.label, value: opt.value };
|
|
688
|
+
}
|
|
689
|
+
);
|
|
690
|
+
handleSelectChange(selectedOptions);
|
|
309
691
|
} else {
|
|
310
|
-
|
|
692
|
+
const selectedOption = options.find(
|
|
693
|
+
(o) => o.value === e.target.value
|
|
694
|
+
);
|
|
695
|
+
handleSelectChange(selectedOption ? [selectedOption] : []);
|
|
311
696
|
}
|
|
312
697
|
}}
|
|
313
698
|
disabled={loading}
|
|
699
|
+
className="select-field"
|
|
314
700
|
>
|
|
315
|
-
{options.map(opt => (
|
|
316
|
-
<option key={opt.value} value={opt.value}>
|
|
701
|
+
{options.map((opt) => (
|
|
702
|
+
<option key={opt.value} value={opt.value}>
|
|
703
|
+
{opt.label}
|
|
704
|
+
</option>
|
|
317
705
|
))}
|
|
318
706
|
</select>
|
|
319
|
-
|
|
707
|
+
|
|
708
|
+
{loading && <div className="loading">Loading options...</div>}
|
|
709
|
+
|
|
320
710
|
{hasNext && !loadingMore && (
|
|
321
|
-
<button
|
|
711
|
+
<button
|
|
712
|
+
type="button"
|
|
713
|
+
onClick={handleLoadMore}
|
|
714
|
+
className="load-more-btn"
|
|
715
|
+
>
|
|
716
|
+
Load More
|
|
717
|
+
</button>
|
|
718
|
+
)}
|
|
719
|
+
|
|
720
|
+
{loadingMore && <div className="loading">Loading more...</div>}
|
|
721
|
+
|
|
722
|
+
<div className="options-info">
|
|
723
|
+
Loaded {totalLoadedOptions} options{hasNext ? "" : " (all loaded)"}
|
|
724
|
+
</div>
|
|
725
|
+
|
|
726
|
+
{field.description && (
|
|
727
|
+
<div className="field-description">{field.description}</div>
|
|
322
728
|
)}
|
|
323
|
-
{loadingMore && <div>Loading more...</div>}
|
|
324
|
-
<div>Loaded {totalLoadedOptions} options{hasNext ? '' : ' (all loaded)'}</div>
|
|
325
|
-
{field.description && <div style={{ fontSize: 'smaller', color: '#666' }}>{field.description}</div>}
|
|
326
729
|
</div>
|
|
327
730
|
);
|
|
328
731
|
}
|
|
329
732
|
```
|
|
330
733
|
|
|
331
|
-
**
|
|
332
|
-
|
|
333
|
-
```ts
|
|
334
|
-
interface UseFieldOptionsReturn {
|
|
335
|
-
options: Array<{ label: string; value: string }>;
|
|
336
|
-
loading: boolean;
|
|
337
|
-
loadingMore: boolean;
|
|
338
|
-
hasNext: boolean;
|
|
339
|
-
loadMore: () => Promise<void>;
|
|
340
|
-
error: string | null;
|
|
341
|
-
search: (query: string) => void;
|
|
342
|
-
refresh: () => void;
|
|
343
|
-
totalLoadedOptions: number;
|
|
344
|
-
}
|
|
345
|
-
```
|
|
734
|
+
### **Google Drive Picker Fields**
|
|
346
735
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
For fields requiring Google Drive file selection, the field type will be `google-files-picker-select` or similar. These fields provide an `optionsSource` with a method to open the Google Files Picker dialog.
|
|
350
|
-
|
|
351
|
-
**Example: GoogleFilesPickerField Component**
|
|
736
|
+
For Google Drive file picker fields, handle the file selection flow. These fields also work with `{ label, value }` objects:
|
|
352
737
|
|
|
353
738
|
```tsx
|
|
354
739
|
function GoogleFilesPickerField({ field, value, onChange, isMulti = false }) {
|
|
@@ -357,27 +742,47 @@ function GoogleFilesPickerField({ field, value, onChange, isMulti = false }) {
|
|
|
357
742
|
await field.optionsSource.openGoogleFilesPicker({
|
|
358
743
|
onComplete: async (files) => {
|
|
359
744
|
if (isMulti) {
|
|
360
|
-
|
|
745
|
+
// For multi-select, ensure we have an array of { label, value } objects
|
|
746
|
+
const formattedFiles = files.map((file) => ({
|
|
747
|
+
label: file.label || file.name || file.value,
|
|
748
|
+
value: file.value || file.id,
|
|
749
|
+
}));
|
|
750
|
+
onChange(formattedFiles);
|
|
361
751
|
} else {
|
|
362
|
-
|
|
752
|
+
// For single select, ensure we have a single { label, value } object
|
|
753
|
+
const formattedFile = {
|
|
754
|
+
label: files[0]?.label || files[0]?.name || files[0]?.value,
|
|
755
|
+
value: files[0]?.value || files[0]?.id,
|
|
756
|
+
};
|
|
757
|
+
onChange(formattedFile);
|
|
363
758
|
}
|
|
364
759
|
},
|
|
365
760
|
onError: async (pickerError) => {
|
|
366
|
-
|
|
761
|
+
console.error("Google Files Picker error:", pickerError);
|
|
762
|
+
alert("Failed to pick files: " + pickerError);
|
|
367
763
|
},
|
|
368
764
|
});
|
|
369
765
|
}
|
|
370
766
|
}
|
|
371
767
|
|
|
372
768
|
return (
|
|
373
|
-
<div>
|
|
374
|
-
<label
|
|
375
|
-
|
|
769
|
+
<div className="field-container">
|
|
770
|
+
<label className="field-label">
|
|
771
|
+
{field.label}
|
|
772
|
+
{field.required && <span className="required"> *</span>}
|
|
773
|
+
</label>
|
|
774
|
+
|
|
775
|
+
<button
|
|
776
|
+
type="button"
|
|
777
|
+
onClick={handlePickFiles}
|
|
778
|
+
className="google-picker-btn"
|
|
779
|
+
>
|
|
376
780
|
Pick from Google Drive
|
|
377
781
|
</button>
|
|
782
|
+
|
|
378
783
|
{value && (
|
|
379
|
-
<div>
|
|
380
|
-
<strong>Selected file{isMulti ?
|
|
784
|
+
<div className="selected-files">
|
|
785
|
+
<strong>Selected file{isMulti ? "s" : ""}:</strong>
|
|
381
786
|
<ul>
|
|
382
787
|
{(isMulti ? value : [value]).map((file, idx) => (
|
|
383
788
|
<li key={file.value || idx}>{file.label || file.value}</li>
|
|
@@ -385,66 +790,264 @@ function GoogleFilesPickerField({ field, value, onChange, isMulti = false }) {
|
|
|
385
790
|
</ul>
|
|
386
791
|
</div>
|
|
387
792
|
)}
|
|
388
|
-
|
|
793
|
+
|
|
794
|
+
{field.description && (
|
|
795
|
+
<div className="field-description">{field.description}</div>
|
|
796
|
+
)}
|
|
389
797
|
</div>
|
|
390
798
|
);
|
|
391
799
|
}
|
|
392
800
|
```
|
|
393
801
|
|
|
394
|
-
**
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
802
|
+
### **Generic Form Field Component**
|
|
803
|
+
|
|
804
|
+
Create a reusable component that handles different field types with proper value handling:
|
|
805
|
+
|
|
806
|
+
```tsx
|
|
807
|
+
function FormField({ field, value, onChange }) {
|
|
808
|
+
switch (field.type) {
|
|
809
|
+
case "text":
|
|
810
|
+
case "email":
|
|
811
|
+
case "password":
|
|
812
|
+
case "number":
|
|
813
|
+
return (
|
|
814
|
+
<div className="field-container">
|
|
815
|
+
<label className="field-label">
|
|
816
|
+
{field.label}
|
|
817
|
+
{field.required && <span className="required"> *</span>}
|
|
818
|
+
</label>
|
|
819
|
+
<input
|
|
820
|
+
type={field.type}
|
|
821
|
+
value={value || ""}
|
|
822
|
+
onChange={(e) => onChange(e.target.value)}
|
|
823
|
+
placeholder={field.placeholder}
|
|
824
|
+
disabled={field.disabled}
|
|
825
|
+
className="text-input"
|
|
826
|
+
/>
|
|
827
|
+
{field.description && (
|
|
828
|
+
<div className="field-description">{field.description}</div>
|
|
829
|
+
)}
|
|
830
|
+
</div>
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
case "checkbox":
|
|
834
|
+
return (
|
|
835
|
+
<div className="field-container">
|
|
836
|
+
<label className="field-label">
|
|
837
|
+
<input
|
|
838
|
+
type="checkbox"
|
|
839
|
+
checked={value || false}
|
|
840
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
841
|
+
disabled={field.disabled}
|
|
842
|
+
className="checkbox-input"
|
|
843
|
+
/>
|
|
844
|
+
{field.label}
|
|
845
|
+
{field.required && <span className="required"> *</span>}
|
|
846
|
+
</label>
|
|
847
|
+
{field.description && (
|
|
848
|
+
<div className="field-description">{field.description}</div>
|
|
849
|
+
)}
|
|
850
|
+
</div>
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
case "select":
|
|
854
|
+
return (
|
|
855
|
+
<SelectField
|
|
856
|
+
field={field}
|
|
857
|
+
value={value}
|
|
858
|
+
onChange={onChange}
|
|
859
|
+
isMulti={false}
|
|
860
|
+
/>
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
case "multi-select":
|
|
864
|
+
return (
|
|
865
|
+
<SelectField
|
|
866
|
+
field={field}
|
|
867
|
+
value={value}
|
|
868
|
+
onChange={onChange}
|
|
869
|
+
isMulti={true}
|
|
870
|
+
/>
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
case "google-files-picker-select":
|
|
874
|
+
return (
|
|
875
|
+
<GoogleFilesPickerField
|
|
876
|
+
field={field}
|
|
877
|
+
value={value}
|
|
878
|
+
onChange={onChange}
|
|
879
|
+
isMulti={false}
|
|
880
|
+
/>
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
case "google-files-picker-multi-select":
|
|
884
|
+
return (
|
|
885
|
+
<GoogleFilesPickerField
|
|
886
|
+
field={field}
|
|
887
|
+
value={value}
|
|
888
|
+
onChange={onChange}
|
|
889
|
+
isMulti={true}
|
|
890
|
+
/>
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
default:
|
|
894
|
+
return (
|
|
895
|
+
<div className="field-container">
|
|
896
|
+
<label className="field-label">
|
|
897
|
+
{field.label} (Unsupported type: {field.type})
|
|
898
|
+
</label>
|
|
899
|
+
</div>
|
|
900
|
+
);
|
|
901
|
+
}
|
|
408
902
|
}
|
|
409
903
|
```
|
|
410
904
|
|
|
411
|
-
**Integration Tips:**
|
|
412
|
-
- Use these fields as controlled components in your form library (Formik, React Hook Form, etc.).
|
|
413
|
-
- Manage `value` and `onChange` via your form state.
|
|
414
|
-
- Combine with other input types to build a complete dynamic configuration form.
|
|
415
|
-
|
|
416
905
|
---
|
|
417
906
|
|
|
418
|
-
|
|
907
|
+
### **Error Handling and Loading States**
|
|
908
|
+
|
|
909
|
+
```tsx
|
|
910
|
+
function ConnectorManager() {
|
|
911
|
+
const { data: connectors, isLoading, error, refetch } = useConnectors();
|
|
912
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
913
|
+
|
|
914
|
+
const handleRetry = () => {
|
|
915
|
+
setRetryCount((prev) => prev + 1);
|
|
916
|
+
refetch();
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
if (isLoading) {
|
|
920
|
+
return (
|
|
921
|
+
<div className="loading-container">
|
|
922
|
+
<div className="spinner"></div>
|
|
923
|
+
<p>Loading your integrations...</p>
|
|
924
|
+
</div>
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (error) {
|
|
929
|
+
return (
|
|
930
|
+
<div className="error-container">
|
|
931
|
+
<h3>Failed to load integrations</h3>
|
|
932
|
+
<p>{error.message}</p>
|
|
933
|
+
<button onClick={handleRetry} className="retry-btn">
|
|
934
|
+
Retry ({retryCount} attempts)
|
|
935
|
+
</button>
|
|
936
|
+
</div>
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return (
|
|
941
|
+
<div className="connector-list">
|
|
942
|
+
{connectors?.map((connector) => (
|
|
943
|
+
<ConnectorCard key={connector.id} connector={connector} />
|
|
944
|
+
))}
|
|
945
|
+
</div>
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
```tsx
|
|
951
|
+
function ConfigurationActions({ config }) {
|
|
952
|
+
const queryClient = useQueryClient();
|
|
953
|
+
|
|
954
|
+
const handleAction = async (action) => {
|
|
955
|
+
// Optimistically update the UI
|
|
956
|
+
queryClient.setQueryData(["configurations"], (oldData) => {
|
|
957
|
+
return oldData?.map((c) =>
|
|
958
|
+
c.id === config.id
|
|
959
|
+
? {
|
|
960
|
+
...c,
|
|
961
|
+
status: action.actionType === "ENABLE" ? "ENABLED" : "DISABLED",
|
|
962
|
+
}
|
|
963
|
+
: c
|
|
964
|
+
);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
const result = await action.onClick();
|
|
969
|
+
if (result?.status === "SUCCESS") {
|
|
970
|
+
// Invalidate and refetch to ensure consistency
|
|
971
|
+
queryClient.invalidateQueries(["configurations"]);
|
|
972
|
+
}
|
|
973
|
+
} catch (error) {
|
|
974
|
+
// Revert optimistic update on error
|
|
975
|
+
queryClient.invalidateQueries(["configurations"]);
|
|
976
|
+
console.error("Action failed:", error);
|
|
977
|
+
}
|
|
978
|
+
};
|
|
419
979
|
|
|
420
|
-
|
|
421
|
-
-
|
|
422
|
-
|
|
423
|
-
|
|
980
|
+
return (
|
|
981
|
+
<div className="config-actions">
|
|
982
|
+
{config.actions?.map((action) => (
|
|
983
|
+
<button
|
|
984
|
+
key={action.name}
|
|
985
|
+
onClick={() => handleAction(action)}
|
|
986
|
+
className={`action-btn ${action.actionType.toLowerCase()}`}
|
|
987
|
+
>
|
|
988
|
+
{action.name}
|
|
989
|
+
</button>
|
|
990
|
+
))}
|
|
991
|
+
</div>
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
```
|
|
424
995
|
|
|
425
996
|
---
|
|
426
997
|
|
|
427
|
-
##
|
|
998
|
+
## 🚨 Troubleshooting
|
|
999
|
+
|
|
1000
|
+
### **Common Issues**
|
|
428
1001
|
|
|
429
|
-
|
|
1002
|
+
1. **"Invalid tenant ID" error**
|
|
430
1003
|
|
|
431
|
-
|
|
432
|
-
|
|
1004
|
+
- Ensure your `tenantId` is a valid string and matches your user/organization identifier
|
|
1005
|
+
- Check that the tenant has proper permissions in your Fastn space
|
|
1006
|
+
|
|
1007
|
+
2. **"Space not found" error**
|
|
1008
|
+
|
|
1009
|
+
- Verify your `spaceId` is correct
|
|
1010
|
+
- Ensure your auth token has access to the specified space
|
|
1011
|
+
|
|
1012
|
+
3. **Configuration form not loading**
|
|
1013
|
+
|
|
1014
|
+
- Check that the `configurationId` is valid and exists
|
|
1015
|
+
- Ensure the connector is properly activated before trying to configure it
|
|
1016
|
+
|
|
1017
|
+
4. **Google Drive picker not working**
|
|
1018
|
+
- Verify Google Drive connector is properly configured in your Fastn space
|
|
1019
|
+
- Check that the user has granted necessary permissions
|
|
1020
|
+
|
|
1021
|
+
### **Debug Mode**
|
|
1022
|
+
|
|
1023
|
+
Enable debug logging to troubleshoot issues:
|
|
1024
|
+
|
|
1025
|
+
```tsx
|
|
1026
|
+
const fastnConfig = {
|
|
1027
|
+
environment: "LIVE",
|
|
1028
|
+
authToken: "your-auth-token",
|
|
1029
|
+
tenantId: "your-tenant-id",
|
|
1030
|
+
spaceId: "your-space-id",
|
|
1031
|
+
debug: true, // Enable debug logging
|
|
1032
|
+
};
|
|
433
1033
|
```
|
|
434
1034
|
|
|
435
1035
|
---
|
|
436
1036
|
|
|
437
|
-
##
|
|
1037
|
+
## 📚 Additional Resources
|
|
438
1038
|
|
|
439
|
-
|
|
1039
|
+
- [Fastn.ai Documentation](https://docs.fastn.ai/)
|
|
1040
|
+
- [React Query Documentation](https://tanstack.com/query/latest)
|
|
1041
|
+
- [Fastn Community](https://community.fastn.ai/)
|
|
440
1042
|
|
|
441
1043
|
---
|
|
442
1044
|
|
|
443
|
-
##
|
|
1045
|
+
## 🤝 Contributing
|
|
444
1046
|
|
|
445
|
-
|
|
446
|
-
- Documentation: [https://docs.fastn.ai](https://docs.fastn.ai)
|
|
1047
|
+
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
|
447
1048
|
|
|
448
1049
|
---
|
|
449
1050
|
|
|
450
|
-
|
|
1051
|
+
## 📄 License
|
|
1052
|
+
|
|
1053
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|