UNPKG

@dev-fastn-ai/react-core

Version:

React hooks and components for integrating Fastn AI connector marketplace into your applications. Built on top of @fastn-ai/core with React Query for optimal performance.

1,332 lines (1,100 loc) 37.1 kB
# [Fastn.ai](http://fastn.ai/) React Core Documentation A React library for integrating **Fastn AI connectors** into your application. It provides powerful hooks to manage: - Connector listing, activation, and deactivation - Configuration form rendering and submission - Seamless integration with React Query for state management This enables you to **build fully custom UIs** on top of Fastn's powerful data and logic engine. --- ## 📦 Installation Install the core library: ```bash npm install @fastn-ai/react-core ``` Also, make sure you install the required **peer dependencies**: ```bash npm install react react-dom @tanstack/react-query ``` > Requires React 18+ --- ## 🏗️ Fastn Architecture Concepts Before diving into the code, let's understand the key Fastn concepts and terminology: ### **Space (Workspace)** 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. ### **Tenant** A **Tenant** represents a user, team, or organization within your application. Each tenant has isolated data and configurations. For example: - A single user account - A team within your app - An organization or company - A client's workspace ### **Connector** A **Connector** represents an integration with an external service (like Slack, Google Drive, etc.). Connectors define what external services your app can connect to. ### **Configuration** A **Configuration** is a specific instance of a connector with saved settings and authentication. For example: - A Slack workspace connection with specific channels selected - A Google Drive connection with specific folders configured - A database connection with connection parameters ### **Configuration ID** A **Configuration ID** is a unique identifier that represents a specific configuration instance. This ID is used to: - Load existing configurations - Update configuration settings - Manage the lifecycle of a specific integration --- ## ⚙️ Features - **Connector Management**: List, activate, and deactivate connectors - **Tenant Isolation**: Each tenant has its own isolated connector state and configurations - **Configuration Persistence**: Save and retrieve configurations using unique `configurationId`s - **Dynamic Forms**: Render configuration forms using Fastn's form schema - **React Query Integration**: Built-in support for efficient caching and request handling - **Authentication Flow**: Handle OAuth and custom authentication flows seamlessly --- ## 🚀 Getting Started ### 1. **Wrap Your App with `FastnProvider`** This sets up Fastn in your app and gives you access to its hooks and logic. ```tsx import { FastnProvider } from "@fastn-ai/react-core"; const fastnConfig = { environment: "LIVE", // "LIVE", "DRAFT", or a custom environment string for widgets authToken: "your-auth-token", // Your app's access token, authenticated through Fastn Custom Auth tenantId: "your-tenant-id", // A unique ID representing the user, team, or organization spaceId: "your-space-id", // Fastn Space ID (also called Workspace ID) - groups all connectors and configurations }; function App() { return ( <FastnProvider config={fastnConfig}> {/* Your app components */} </FastnProvider> ); } ``` ### 🔍 Configuration Field Reference | Field | Description | | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `environment` | The widget environment: use `"LIVE"` for production, `"DRAFT"` for preview/testing, or any custom string configured in Fastn | | `authToken` | The token from your authentication flow. Fastn uses this to authenticate the user via the **fastnCustomAuth** flow | | `tenantId` | A unique identifier for the current user or organization. This helps Fastn isolate data per tenant | | `spaceId` | The Fastn **Space ID**, also called the Workspace ID. It groups all connectors, configurations, and flows | --- ### 2. **Use an Existing React Query Client (Optional)** If your app already uses React Query, you can pass your own client: ```tsx import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { FastnProvider } from "@fastn-ai/react-core"; const queryClient = new QueryClient(); const fastnConfig = { environment: "LIVE", authToken: "your-auth-token", tenantId: "your-tenant-id", spaceId: "your-space-id", }; function App() { return ( <QueryClientProvider client={queryClient}> <FastnProvider config={fastnConfig}> {/* Your app components */} </FastnProvider> </QueryClientProvider> ); } ``` --- ## 🧩 Core Hooks & Types ### **Connector Types** ```tsx interface Connector { id: string; name: string; description: string; imageUri?: string; status: ConnectorStatus; actions: ConnectorAction[]; } enum ConnectorStatus { ACTIVE = "ACTIVE", INACTIVE = "INACTIVE", ALL = "ALL", } interface ConnectorAction { name: string; actionType: ConnectorActionType | string; onClick?: () => Promise<ConnectorActionResult>; } interface ConnectorActionResult { status: "SUCCESS" | "ERROR" | "CANCELLED"; } enum ConnectorActionType { ACTIVATION = "ACTIVATION", DEACTIVATION = "DEACTIVATION", NONE = "NONE", } ``` ### **Configuration Types** ```tsx interface Configuration { id: string; connectorId: string; name: string; description: string; imageUri?: string; status: ConfigurationStatus; actions: ConfigurationAction[]; } enum ConfigurationStatus { ENABLED = "ENABLED", DISABLED = "DISABLED", PENDING = "PENDING", } interface ConfigurationAction { name: string; actionType: ConfigurationActionType | string; onClick?: () => Promise<ConfigurationActionResult>; } interface ConfigurationActionResult { status: "SUCCESS" | "ERROR" | "CANCELLED"; } enum ConfigurationActionType { ENABLE = "ENABLE", DISABLE = "DISABLE", DELETE = "DELETE", UPDATE = "UPDATE", } ``` ### **Configuration Form Types** ```tsx type Primitive = string | number | boolean | null | undefined; interface ConfigurationForm { name: string; description: string; imageUri: string; fields: ConnectorField[]; submitHandler: (formData: FormData) => Promise<void>; } type FormData = | Record< string, Record<string, Primitive> | Record<string, Primitive>[] | undefined | null > | Record< string, Record<string, Primitive> | Record<string, Primitive>[] | undefined | null >[] | undefined | null; interface ConnectorField { readonly name: string; readonly key: string; readonly label: string; readonly type: ConnectorFieldType | string; readonly required: boolean; readonly placeholder: string; readonly description: string; readonly hidden?: boolean; readonly disabled?: boolean; readonly initialValue?: | Record<string, Primitive> | Record<string, Primitive>[] | Primitive | Primitive[]; readonly optionsSource?: SelectOptionSource; readonly configs?: ConnectorFieldConfig; } interface ConnectorFieldConfig { readonly selection?: SelectionConfig; readonly disable?: boolean; readonly label?: string; readonly hideOption?: HideOptionConfig; } interface SelectionConfig { readonly enable: boolean; readonly flowId: string; readonly sameProject: boolean; readonly list: readonly any[]; readonly type: "OFFSET" | "CURSOR"; readonly offset: number; readonly isIndependent: boolean; readonly fileTypes: readonly string[]; readonly source: FlowConfig; readonly destination: FlowConfig; readonly isEditKeys: boolean; readonly isAddFields: boolean; } interface FlowConfig { readonly flowId: string; readonly isSameProject: boolean; } interface HideOptionConfig { readonly enable: boolean; readonly hideBasedOnValue: boolean; readonly key: string; readonly operation: "!=" | "==" | ">" | "<" | ">=" | "<=" | "contains" | "not_contains"; readonly value: string; } ``` ### Configuration Usage Example Here's an example of how the configuration types work with connector fields in React: ```tsx // Example configuration structure const fieldConfig: ConnectorFieldConfig = { selection: { enable: true, flowId: "asanaGetWorkspaces", sameProject: false, list: [], type: "OFFSET", offset: 10, isIndependent: false, fileTypes: [], source: { flowId: "", isSameProject: false }, destination: { flowId: "", isSameProject: false }, isEditKeys: false, isAddFields: false }, disable: false, label: "Select Workspace", hideOption: { enable: false, hideBasedOnValue: false, key: "", operation: "!=", value: "" } }; // Example connector field with configuration const connectorField: ConnectorField = { name: "workspace", key: "workspace", label: "Workspace", type: "select", required: true, placeholder: "Select a workspace", description: "Choose the workspace to connect to", configs: fieldConfig }; // Using the field in a React component function WorkspaceField({ field }: { field: ConnectorField }) { const { configs } = field; // Access configuration properties const isSelectionEnabled = configs?.selection?.enable; const flowId = configs?.selection?.flowId; const isDisabled = configs?.disable; return ( <div className="field-container"> <label>{field.label}</label> {isSelectionEnabled && ( <p>Selection enabled for flow: {flowId}</p> )} <select disabled={isDisabled}> {/* Field options */} </select> </div> ); } ``` --- ## 🔄 Complete Integration Workflows ### **Workflow 1: Setting Up Your First Slack Integration** Let's walk through a complete example of setting up a Slack integration from scratch. #### Step 1: List Available Connectors First, show users what connectors are available: ```tsx import { useConnectors } from "@fastn-ai/react-core"; function ConnectorList() { const { data: connectors, isLoading, error } = useConnectors(); if (isLoading) return <div>Loading available integrations...</div>; if (error) return <div>Error loading connectors: {error.message}</div>; return ( <div className="connector-grid"> <h2>Available Integrations</h2> {connectors?.map((connector) => ( <div key={connector.id} className="connector-card"> <img src={connector.imageUri} alt={connector.name} /> <h3>{connector.name}</h3> <p>{connector.description}</p> {connector.status === "ACTIVE" && ( <span className="status-badge connected">Connected</span> )} {connector.actions?.map((action) => ( <button key={action.name} onClick={action.onClick} className={`action-btn ${action.actionType.toLowerCase()}`} > {action.name} </button> ))} </div> ))} </div> ); } ``` #### Step 2: List Configurations After Connector Activation After a connector is activated, you can list its configurations: ```tsx import { useConfigurations } from "@fastn-ai/react-core"; function ConfigurationList({ configurationId }) { const { data: configurations, isLoading, error } = useConfigurations({ configurationId }); const [selectedConfig, setSelectedConfig] = useState(null); if (isLoading) return <div>Loading configurations...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div className="configuration-list"> <h2>Your Integrations</h2> {configurations?.map((config) => ( <div key={config.id} className="config-card"> <div className="config-info"> <img src={config.imageUri} alt={config.name} /> <div> <h3>{config.name}</h3> <p>{config.description}</p> </div> </div> <div className="config-actions"> {config.status === "ENABLED" && ( <span className="status-badge enabled">Active</span> )} {config.actions?.map((action) => ( <button key={action.name} onClick={async () => { const result = await action.onClick(); if (action.actionType === "ENABLE" && result?.status === "SUCCESS") { // Show configuration form for new setup setSelectedConfig(config); } else if (action.actionType === "UPDATE" && result?.status === "SUCCESS") { // Show configuration form for editing setSelectedConfig(config); } }} className={`action-btn ${action.actionType.toLowerCase()}`} > {action.name} </button> ))} </div> </div> ))} {selectedConfig && ( <ConfigurationForm configurationId={selectedConfig.id} onClose={() => setSelectedConfig(null)} /> )} </div> ); } ``` #### Step 3: Load Configuration Form When a configuration is selected (either for new setup or editing), show the configuration form: ```tsx import { useConfigurationForm } from "@fastn-ai/react-core"; function ConfigurationForm({ configurationId, onClose }) { const { data: configurationForm, isLoading, error, handleSubmit, } = useConfigurationForm({ configurationId }); const [formData, setFormData] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); // Pre-populate form with existing values if editing useEffect(() => { if (configurationForm?.fields) { const initialData = {}; configurationForm.fields.forEach((field) => { if (field.initialValue !== undefined) { initialData[field.key] = field.initialValue; } }); setFormData(initialData); } }, [configurationForm]); if (isLoading) return <div>Loading configuration form...</div>; if (error) return <div>Error: {error.message}</div>; const onSubmit = async (e) => { e.preventDefault(); setIsSubmitting(true); try { await handleSubmit({ formData }); console.log("Configuration saved successfully!"); onClose(); } catch (error) { console.error("Failed to save configuration:", error); } finally { setIsSubmitting(false); } }; return ( <div className="modal"> <form onSubmit={onSubmit} className="configuration-form"> <h2>Configure {configurationForm.name}</h2> <p>{configurationForm.description}</p> {configurationForm.fields.map((field) => ( <FormField key={field.key} field={field} value={formData[field.key]} onChange={(value) => setFormData((prev) => ({ ...prev, [field.key]: value })) } /> ))} <div className="form-actions"> <button type="button" onClick={onClose}>Cancel</button> <button type="submit" disabled={isSubmitting}> {isSubmitting ? "Saving..." : "Save Configuration"} </button> </div> </form> </div> ); } ``` ### **Workflow 2: Managing Existing Configurations** Now let's show how to manage existing configurations - viewing, editing, and disabling them. #### Step 1: List Existing Configurations ```tsx import { useConfigurations } from "@fastn-ai/react-core"; function ConfigurationManager({ configurationId }) { const { data: configurations, isLoading, error, } = useConfigurations({ configurationId }); const [selectedConfig, setSelectedConfig] = useState(null); if (isLoading) return <div>Loading your integrations...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div className="configuration-manager"> <h2>Your Integrations</h2> {configurations?.map((config) => ( <div key={config.id} className="config-card"> <div className="config-info"> <img src={config.imageUri} alt={config.name} /> <div> <h3>{config.name}</h3> <p>{config.description}</p> </div> </div> <div className="config-actions"> {config.status === "ENABLED" && ( <span className="status-badge enabled">Active</span> )} {config.actions?.map((action) => ( <button key={action.name} onClick={async () => { const result = await action.onClick(); if ( action.actionType === "UPDATE" && result?.status === "SUCCESS" ) { setSelectedConfig(config); } }} className={`action-btn ${action.actionType.toLowerCase()}`} > {action.name} </button> ))} </div> </div> ))} {selectedConfig && ( <ConfigurationForm configurationId={selectedConfig.id} onClose={() => setSelectedConfig(null)} /> )} </div> ); } ``` #### Step 2: Disable Configuration ```tsx function ConfigActions({ config }) { const handleDisable = async (action) => { if (action.actionType === "DISABLE") { const result = await action.onClick(); if (result?.status === "SUCCESS") { console.log("Configuration disabled successfully"); // Refresh the configuration list } } }; return ( <div className="config-actions"> {config.actions?.map((action) => ( <button key={action.name} onClick={() => handleDisable(action)} className={`action-btn ${action.actionType.toLowerCase()}`} > {action.name} </button> ))} </div> ); } ``` #### Step 3: Form Field Value Handling The form fields handle different value types based on the field type: - **Select fields**: Always return `{ label: string, value: string }` objects - **Multi-select fields**: Always return `{ label: string, value: string }[]` arrays - **Google Drive picker fields**: Always return `{ label: string, value: string }` objects or arrays - **Other fields**: Return primitive values (string, number, boolean) ```tsx // Example form data structure const formData = { // Select field - single object channel: { label: "General", value: "C123456" }, // Multi-select field - array of objects channels: [ { label: "General", value: "C123456" }, { label: "Random", value: "C789012" } ], // Google Drive picker - single object folder: { label: "My Documents", value: "folder_id_123" }, // Google Drive picker multi - array of objects files: [ { label: "document1.pdf", value: "file_id_1" }, { label: "document2.pdf", value: "file_id_2" } ], // Text field - primitive webhookUrl: "https://hooks.slack.com/...", // Boolean field - primitive enableNotifications: true }; ``` --- ## 🎨 Form Field Components ### **Select and Multi-Select Fields** 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: ```tsx import { useFieldOptions } from "@fastn-ai/react-core"; function SelectField({ field, value, onChange, isMulti = false, context = {} }) { // context contains all form values and is used to fetch dependent options const { options, loading, loadingMore, hasNext, loadMore, error, search, totalLoadedOptions, } = useFieldOptions(field, context); function handleInputChange(e) { search(e.target.value); } function handleLoadMore() { if (hasNext && !loadingMore) loadMore(); } function handleSelectChange(selectedOptions) { if (isMulti) { // For multi-select, value should be an array of { label, value } objects const selectedValues = selectedOptions.map(option => ({ label: option.label, value: option.value })); onChange(selectedValues); } else { // For single select, value should be a single { label, value } object const selectedValue = selectedOptions[0] ? { label: selectedOptions[0].label, value: selectedOptions[0].value } : null; onChange(selectedValue); } } return ( <div className="field-container"> <label className="field-label"> {field.label} {field.required && <span className="required"> *</span>} </label> {error && ( <div className="error-message"> Error loading options: {error.message} </div> )} <input type="text" placeholder={field.placeholder || `Search ${field.label}`} onChange={handleInputChange} disabled={loading} className="search-input" /> <select multiple={isMulti} value={isMulti ? (value || []).map(v => v.value) : (value?.value || '')} onChange={(e) => { if (isMulti) { const selectedOptions = Array.from(e.target.selectedOptions).map(option => { const opt = options.find(o => o.value === option.value); return { label: opt.label, value: opt.value }; }); handleSelectChange(selectedOptions); } else { const selectedOption = options.find(o => o.value === e.target.value); handleSelectChange(selectedOption ? [selectedOption] : []); } }} disabled={loading} className="select-field" > {options.map((opt) => ( <option key={opt.value} value={opt.value}> {opt.label} </option> ))} </select> {loading && <div className="loading">Loading options...</div>} {hasNext && !loadingMore && ( <button type="button" onClick={handleLoadMore} className="load-more-btn" > Load More </button> )} {loadingMore && <div className="loading">Loading more...</div>} <div className="options-info"> Loaded {totalLoadedOptions} options{hasNext ? "" : " (all loaded)"} </div> {field.description && ( <div className="field-description">{field.description}</div> )} </div> ); } ``` ### **Google Drive Picker Fields** For Google Drive file picker fields, handle the file selection flow. These fields also work with `{ label, value }` objects and support file type filtering: ```tsx function GoogleFilesPickerField({ field, value, onChange, isMulti = false, context = {} }) { async function handlePickFiles() { if (field.optionsSource?.openGoogleFilesPicker) { await field.optionsSource.openGoogleFilesPicker({ onComplete: async (files) => { if (isMulti) { // For multi-select, ensure we have an array of { label, value } objects const formattedFiles = files.map(file => ({ label: file.label || file.name || file.value, value: file.value || file.id })); onChange(formattedFiles); } else { // For single select, ensure we have a single { label, value } object const formattedFile = { label: files[0]?.label || files[0]?.name || files[0]?.value, value: files[0]?.value || files[0]?.id }; onChange(formattedFile); } }, onError: async (pickerError) => { console.error("Google Files Picker error:", pickerError); alert("Failed to pick files: " + pickerError); }, fileTypes: field?.optionsSource?.fileTypes, }); } } return ( <div className="field-container"> <label className="field-label"> {field.label} {field.required && <span className="required"> *</span>} </label> <button type="button" onClick={handlePickFiles} className="google-picker-btn" > Pick from Google Drive </button> {value && ( <div className="selected-files"> <strong>Selected file{isMulti ? "s" : ""}:</strong> <ul> {(isMulti ? value : [value]).map((file, idx) => ( <li key={file.value || idx}>{file.label || file.value}</li> ))} </ul> </div> )} {field.description && ( <div className="field-description">{field.description}</div> )} </div> ); } ``` ### **Generic Form Field Component** Create a reusable component that handles different field types with proper value handling: ```tsx function FormField({ field, value, onChange, context = {} }) { switch (field.type) { case "text": case "email": case "password": case "number": return ( <div className="field-container"> <label className="field-label"> {field.label} {field.required && <span className="required"> *</span>} </label> <input type={field.type} value={value || ""} onChange={(e) => onChange(e.target.value)} placeholder={field.placeholder} disabled={field.disabled} className="text-input" /> {field.description && ( <div className="field-description">{field.description}</div> )} </div> ); case "checkbox": return ( <div className="field-container"> <label className="field-label"> <input type="checkbox" checked={value || false} onChange={(e) => onChange(e.target.checked)} disabled={field.disabled} className="checkbox-input" /> {field.label} {field.required && <span className="required"> *</span>} </label> {field.description && ( <div className="field-description">{field.description}</div> )} </div> ); case "select": return ( <SelectField field={field} value={value} onChange={onChange} isMulti={false} context={context} /> ); case "multi-select": return ( <SelectField field={field} value={value} onChange={onChange} isMulti={true} context={context} /> ); case "google-files-picker-select": return ( <GoogleFilesPickerField field={field} value={value} onChange={onChange} isMulti={false} context={context} /> ); case "google-files-picker-multi-select": return ( <GoogleFilesPickerField field={field} value={value} onChange={onChange} isMulti={true} context={context} /> ); default: return ( <div className="field-container"> <label className="field-label"> {field.label} (Unsupported type: {field.type}) </label> </div> ); } } ``` --- ## 🔗 Field Context Passing The React Core library supports **dynamic field dependencies** where form fields can access and respond to values from other fields in real-time. This enables complex form workflows where the options or behavior of one field depends on the selection made in another field. ### **How Context Passing Works** When a user selects a value in one field, that value becomes available to all other fields through the `context` parameter. This allows dependent fields to: - Load different options based on the parent field's selection - Show/hide fields based on other field values - Update their behavior dynamically ### **Implementation Example** Here's how to implement context passing in your form components: ```tsx import { useConfigurationForm } from "@fastn-ai/react-core"; function ConfigurationForm({ configurationId, connectorId, configuration }) { const { data: configurationForm, isLoading, error } = useConfigurationForm({ configurationId, connectorId, configuration, }); return ( <Formik initialValues={initialValues} onSubmit={handleSubmit} > {({ values, setFieldValue, setFieldTouched, errors, touched }) => ( <Form> {configurationForm.fields .filter((field) => !field.hidden) .map((field) => ( <FormField key={field.name} field={field} form={{ values, errors, touched }} setFieldValue={setFieldValue} setFieldTouched={setFieldTouched} context={values} // Pass all form values as context /> ))} </Form> )} </Formik> ); } // FormField component that passes context to individual fields function FormField({ field, form, setFieldValue, setFieldTouched, context }) { const handleChange = useCallback( (value) => { setFieldValue(field.name, value); setFieldTouched(field.name, true); }, [field.name, setFieldValue, setFieldTouched] ); switch (field.type) { case "select": case "multi-select": return ( <SelectField field={field} onChange={handleChange} value={form.values[field.name]} isMulti={field.type === "multi-select"} context={context} // Pass context to SelectField /> ); // ... other field types } } // SelectField component that uses context function SelectField({ field, onChange, value, isMulti, context }) { // The useFieldOptions hook receives the context and can use it // to fetch options based on other field values const { options, loading, loadingMore, hasNext, loadMore, error, search, refresh, totalLoadedOptions, } = useFieldOptions(field, context); const handleChange = useCallback( (selected) => { onChange(selected); // When this field changes, the context is automatically updated // and passed to all other fields }, [onChange] ); // ... rest of component implementation } ``` ### **Context Structure** The `context` parameter contains all current form values in the following structure: ```tsx // Example context object const context = { workspace: { label: "My Workspace", value: "ws_123" }, channel: { label: "General", value: "ch_456" }, webhookUrl: "https://hooks.slack.com/...", enableNotifications: true, // ... other field values }; ``` ### **Use Cases** 1. **Workspace Channel Selection**: When a user selects a workspace, the channel field loads only channels from that workspace. 2. **Database Table Selection**: When a database is selected, the table field shows only tables from that database. 3. **Conditional Field Display**: Show/hide fields based on other field values. 4. **Dynamic Option Loading**: Load different options based on parent field selections. ### **Best Practices** 1. **Always pass context**: Make sure to pass the `context` parameter to all field components that need access to other field values. 2. **Handle loading states**: When context changes, dependent fields may need to reload their options, so handle loading states appropriately. 3. **Optimize re-renders**: Use `useCallback` and `useMemo` to prevent unnecessary re-renders when context changes. 4. **Error handling**: Handle cases where context-dependent operations fail gracefully. ### **Advanced Example: Multi-level Dependencies** ```tsx // Example: Workspace Channel User selection function MultiLevelSelectForm() { const [formValues, setFormValues] = useState({}); return ( <div> {/* Workspace selection */} <SelectField field={workspaceField} value={formValues.workspace} onChange={(value) => setFormValues(prev => ({ ...prev, workspace: value }))} context={formValues} /> {/* Channel selection - depends on workspace */} <SelectField field={channelField} value={formValues.channel} onChange={(value) => setFormValues(prev => ({ ...prev, channel: value }))} context={formValues} // Has access to workspace value /> {/* User selection - depends on both workspace and channel */} <SelectField field={userField} value={formValues.user} onChange={(value) => setFormValues(prev => ({ ...prev, user: value }))} context={formValues} // Has access to both workspace and channel values /> </div> ); } ``` --- ### **Error Handling and Loading States** ```tsx function ConnectorManager() { const { data: connectors, isLoading, error, refetch } = useConnectors(); const [retryCount, setRetryCount] = useState(0); const handleRetry = () => { setRetryCount((prev) => prev + 1); refetch(); }; if (isLoading) { return ( <div className="loading-container"> <div className="spinner"></div> <p>Loading your integrations...</p> </div> ); } if (error) { return ( <div className="error-container"> <h3>Failed to load integrations</h3> <p>{error.message}</p> <button onClick={handleRetry} className="retry-btn"> Retry ({retryCount} attempts) </button> </div> ); } return ( <div className="connector-list"> {connectors?.map((connector) => ( <ConnectorCard key={connector.id} connector={connector} /> ))} </div> ); } ``` ```tsx function ConfigurationActions({ config }) { const queryClient = useQueryClient(); const handleAction = async (action) => { // Optimistically update the UI queryClient.setQueryData(["configurations"], (oldData) => { return oldData?.map((c) => c.id === config.id ? { ...c, status: action.actionType === "ENABLE" ? "ENABLED" : "DISABLED", } : c ); }); try { const result = await action.onClick(); if (result?.status === "SUCCESS") { // Invalidate and refetch to ensure consistency queryClient.invalidateQueries(["configurations"]); } } catch (error) { // Revert optimistic update on error queryClient.invalidateQueries(["configurations"]); console.error("Action failed:", error); } }; return ( <div className="config-actions"> {config.actions?.map((action) => ( <button key={action.name} onClick={() => handleAction(action)} className={`action-btn ${action.actionType.toLowerCase()}`} > {action.name} </button> ))} </div> ); } ``` --- ## 🚨 Troubleshooting ### **Common Issues** 1. **"Invalid tenant ID" error** - Ensure your `tenantId` is a valid string and matches your user/organization identifier - Check that the tenant has proper permissions in your Fastn space 2. **"Space not found" error** - Verify your `spaceId` is correct - Ensure your auth token has access to the specified space 3. **Configuration form not loading** - Check that the `configurationId` is valid and exists - Ensure the connector is properly activated before trying to configure it 4. **Google Drive picker not working** - Verify Google Drive connector is properly configured in your Fastn space - Check that the user has granted necessary permissions ### **Debug Mode** Enable debug logging to troubleshoot issues: ```tsx const fastnConfig = { environment: "LIVE", authToken: "your-auth-token", tenantId: "your-tenant-id", spaceId: "your-space-id", debug: true, // Enable debug logging }; ``` --- ## 📚 Additional Resources - [Fastn.ai Documentation](https://docs.fastn.ai/) - [React Query Documentation](https://tanstack.com/query/latest) - [Fastn Community](https://community.fastn.ai/) --- ## 🤝 Contributing We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details. --- ## 📄 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.