@fastn-ai/react-core
TypeScript icon, indicating that this package has built-in type declarations

1.0.3 • Public • Published

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:

npm install @fastn-ai/react-core

Also, make sure you install the required peer dependencies:

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 configurationIds
  • 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.

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:

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

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

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

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;
}

🔄 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:

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:

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:

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

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

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)
// 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:

import { useFieldOptions } from "@fastn-ai/react-core";

function SelectField({ field, value, onChange, isMulti = false }) {
  const {
    options,
    loading,
    loadingMore,
    hasNext,
    loadMore,
    error,
    search,
    totalLoadedOptions,
  } = useFieldOptions(field);

  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:

function GoogleFilesPickerField({ field, value, onChange, isMulti = false }) {
  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);
        },
      });
    }
  }

  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:

function FormField({ field, value, onChange }) {
  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}
        />
      );
      
    case "multi-select":
      return (
        <SelectField 
          field={field} 
          value={value} 
          onChange={onChange} 
          isMulti={true}
        />
      );
      
    case "google-files-picker-select":
      return (
        <GoogleFilesPickerField 
          field={field} 
          value={value} 
          onChange={onChange} 
          isMulti={false}
        />
      );
      
    case "google-files-picker-multi-select":
      return (
        <GoogleFilesPickerField 
          field={field} 
          value={value} 
          onChange={onChange} 
          isMulti={true}
        />
      );
      
    default:
      return (
        <div className="field-container">
          <label className="field-label">
            {field.label} (Unsupported type: {field.type})
          </label>
        </div>
      );
  }
}

Error Handling and Loading States

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>
  );
}
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:

const fastnConfig = {
  environment: "LIVE",
  authToken: "your-auth-token",
  tenantId: "your-tenant-id",
  spaceId: "your-space-id",
  debug: true, // Enable debug logging
};

📚 Additional Resources


🤝 Contributing

We welcome contributions! Please see our Contributing Guide for details.


📄 License

This project is licensed under the MIT License - see the LICENSE file for details.

Package Sidebar

Install

npm i @fastn-ai/react-core

Weekly Downloads

16

Version

1.0.3

License

MIT

Unpacked Size

144 kB

Total Files

14

Last publish

Collaborators

  • fastn.ai
  • alsulami