# Plugins

URL: https://docs.forms.saastro.io/docs/plugins
> Extend forms with lifecycle hooks, custom fields, validators, and config/value transformers.

# Plugins

The plugin system lets you extend `@saastro/forms` without modifying its source. Plugins can hook into the form lifecycle, register custom field types, add validators, and transform configs or values before submission.

---

## Using Plugins

Create a `PluginManager`, register plugins, and pass it to your form:

```tsx

const pm = new PluginManager();
pm.register(localStoragePlugin);
pm.register(analyticsPlugin);

const config = FormBuilder.create('my-form')
  .usePlugins(pm)
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['email'])
  .build();
```

The `<Form>` component reads the plugin manager from the config — there is no `pluginManager` prop. If you build config objects by hand instead of using `FormBuilder`, set the `pluginManager` property on the config:

```tsx

const config: FormConfig = {
  formId: 'my-form',
  pluginManager: pm,
  fields: {
    /* ... */
  },
  steps: {
    /* ... */
  },
};
```

### Importing

Every plugin is available from the root (as above) **and** from a per-plugin subpath, which guarantees your bundle only carries the plugins you use:

```tsx
// or the whole set: import { ... } from '@saastro/forms/plugins';
```

Subpaths: `analytics`, `autosave`, `databowl`, `hub`, `localeDetector`, `localStorage`, `recaptcha`, `turnstile`. The root import stays supported (bundlers already tree-shake it well); the subpaths make the guarantee explicit and cut parse cost. Note: subpath *types* require `moduleResolution: 'bundler'`, `'node16'` or `'nodenext'` in your tsconfig (same as `@saastro/forms/validation`).

---

## Lifecycle Hooks

Plugins can implement any combination of 6 lifecycle hooks:

| Hook             | When It Fires                    | Arguments                       |
| ---------------- | -------------------------------- | ------------------------------- |
| `onFormInit`     | Form component mounts            | `(config: FormConfig)`          |
| `onFieldChange`  | Any field value changes          | `(fieldName, value, allValues, meta?)` |
| `onStepChange`   | Step navigation occurs           | `(stepId, values)`              |
| `onBeforeSubmit` | Before submission (can be async) | `(values)`                      |
| `onAfterSubmit`  | After successful submission      | `(values, response)`            |
| `onError`        | When an error occurs             | `(error, values?)`              |

Hooks from all registered plugins execute in registration order. An async `onBeforeSubmit` that rejects cancels the submission; errors thrown inside other lifecycle hooks (sync or async) are caught and logged per plugin and don't stop the form or the other plugins.

> **Notes for plugin authors:**
>
> - Lifecycle hooks are invoked with `this` bound to your plugin object, and `onFormInit` receives the real `FormConfig` (so `config.formId` works). Closure state via a factory function (like the built-in `autosavePlugin`) is still the recommended pattern — it survives serialization and is easier to test.
> - `onFieldChange` receives an optional 4th argument `meta: FieldChangeMeta` — `{ formId, getDirtyPaths(), getDirtyValues() }`. The getters are **lazy**: a debounced plugin calls them at flush time and reads the *current* dirty state, not the keystroke's. Three-parameter plugins remain valid.

```tsx

const loggingPlugin = definePlugin({
  name: 'logging',
  version: '1.0.0',
  onFormInit() {
    console.log('Form initialized');
  },
  onFieldChange(fieldName, value) {
    console.log(`Field "${fieldName}" changed to:`, value);
  },
  onBeforeSubmit(values) {
    console.log('About to submit:', values);
  },
  onAfterSubmit(values, response) {
    console.log('Submitted successfully:', response);
  },
  onError(error) {
    console.error('Form error:', error.message);
  },
});
```

---

## Config & Value Transformers

### transformConfig

Runs before the form schema and defaults are built. Use it to inject fields, modify schemas, or add submit actions.

```tsx
const prefixPlugin = definePlugin({
  name: 'prefix',
  version: '1.0.0',
  transformConfig(config) {
    // Inject an extra field into every form
    return {
      ...config,
      fields: {
        ...config.fields,
        _source: {
          type: 'html' as const,
          label: '',
          content: '',
          schema: { required: false },
        },
      },
    };
  },
});
```

### transformValues

Runs before submit actions execute. Use it to add computed values, clean data, or merge extra fields.

```tsx
const timestampPlugin = definePlugin({
  name: 'timestamp',
  version: '1.0.0',
  transformValues(values) {
    return {
      ...values,
      submittedAt: new Date().toISOString(),
      userAgent: navigator.userAgent,
    };
  },
});
```

Transformers from multiple plugins are chained in registration order. Each plugin receives the output of the previous one.

---

## Custom Field Types

Plugins can register new field types with custom React renderers:

```tsx

const SignatureField = ({ name, colSpanItem }: CustomFieldProps) => (
  <div className={colSpanItem}>
    <canvas id={`sig-${name}`} style={{ border: '1px solid #ccc' }} />
  </div>
);

const signaturePlugin = definePlugin({
  name: 'signature',
  version: '1.0.0',
  registerFields() {
    return {
      signature: SignatureField,
    };
  },
});
```

Once registered, use the custom type like any built-in type — `FieldBuilder.type()` accepts custom strings directly, and `.prop()` sets bespoke config props:

```tsx
.addField('sig', (f) =>
  f.type('signature').label('Your Signature').prop('penColor', '#1a1a1a'),
)
```

Custom field components receive the full renderer context: `name`, `fieldConfig`, `control`, `colSpanItem`, plus the DI `components` registry, pre-evaluated `shouldDisable`/`shouldReadOnly`, and the `renderLabel`/`renderFieldIcon` helpers — ready to compose with the exported `FieldWrapper`.

See the **[Custom Fields guide](/docs/custom-fields)** for a complete walkthrough (stepper field example, validation, defaults, conditional logic).

---

## Custom Validators

Register named validators that can be referenced by any field via `.customValidators()`:

```tsx
const validationPlugin = definePlugin({
  name: 'custom-validators',
  version: '1.0.0',
  validators: {
    uniqueEmail: async (value, context) => {
      const res = await fetch(`/api/check-email?email=${value}`);
      const { exists } = await res.json();
      return exists ? 'Email already registered' : true;
    },
    corporateOnly: (value, context) => {
      const email = String(value);
      const freeProviders = ['gmail.com', 'yahoo.com', 'hotmail.com'];
      const domain = email.split('@')[1];
      return freeProviders.includes(domain) ? 'Please use your corporate email' : true;
    },
  },
});
```

Reference validators on fields:

```tsx
.addField('email', (f) =>
  f.type('email')
    .label('Work Email')
    .required()
    .email()
    .customValidators('uniqueEmail', 'corporateOnly')
)
```

Validators are chained as Zod `superRefine` checks and may be async. Return `true` for valid, a string error message for invalid, or `false` for invalid with a generic message.

Two current limitations to be aware of:

- Custom validators only run on fields that **also declare validation rules or a schema** (the `.required().email()` calls above count). A field with only `.customValidators()` is not validated.
- The `ValidationContext` passed at runtime contains `fieldName`, but `allValues` is currently always an empty object — cross-field validation is not possible from a custom validator (use [`crossFieldRules`](/docs/validation#cross-field-validation) instead) — and `abortSignal` is never provided.

---

## Built-in Plugins

The package ships 8 built-in plugins.

> **Instances vs Factories:** Two of the built-in plugins (`localStoragePlugin`, `analyticsPlugin`) are ready-to-use instances — register them directly. The other six (`autosavePlugin`, `databowlPlugin`, `recaptchaPlugin`, `turnstilePlugin`, `hubPlugin`, `localeDetectorPlugin`) are factory functions — call them with parentheses to get a plugin instance (`localeDetectorPlugin()` accepts zero arguments).
>
> ```tsx
> // Instances — register directly (no parentheses)
> pm.register(localStoragePlugin);
> pm.register(analyticsPlugin);
>
> // Factories — call with config (parentheses required)
> pm.register(autosavePlugin({ interval: 30000 }));
> pm.register(databowlPlugin({ token: 'your-databowl-token' }));
> pm.register(recaptchaPlugin({ siteKey: 'your-recaptcha-site-key' }));
> pm.register(turnstilePlugin({ siteKey: 'your-turnstile-site-key' }));
> ```

### localStoragePlugin

Persists form progress to localStorage under the key `form-<formId>`. Clears saved data after successful submission.

```tsx

pm.register(localStoragePlugin);
```

Uses `transformConfig` (restore saved values as field defaults), `onFieldChange` (save on change), and `onAfterSubmit` (clear storage).

> **Note:** the plugin is a shared instance. With multiple forms mounted on the same page at once, the last form to initialize wins the storage slot — progress for the others isn't persisted. One form per page (the normal case) works fully.

### analyticsPlugin

Sends Google Analytics events via `gtag()` for form interactions.

```tsx

pm.register(analyticsPlugin);
```

Tracked events:

- `form_init` — form initialized
- `form_step` — step change (with `step_id`)
- `form_submit` — successful submission (with `success: true`)
- `form_error` — error occurred (with `error_message`)

Events are only sent when `window.gtag` is available; otherwise the plugin is a no-op.

### autosavePlugin

Debounced auto-save — POSTs the current form values to an endpoint after a period of inactivity following a field change (not on a fixed timer).

```tsx

pm.register(
  autosavePlugin({
    interval: 30000, // Debounce interval in ms (default: 60000)
    endpoint: '/api/autosave', // POST endpoint (default: '/api/autosave')
  }),
);
```

The request body is the current form values as JSON (`Content-Type: application/json`). Fetch errors are logged to the console; saving never blocks the user.

### databowlPlugin

[Databowl](https://www.databowl.com/) is a lead management platform; this plugin posts each form submission to its leads API. It injects an HTTP submit action via `transformConfig` and merges `staticFields` into the submitted values via `transformValues` — user-entered values always win: a `staticFields` key is only injected when the form has no non-empty value for it (a dev warning logs any ignored key).

```tsx

pm.register(
  databowlPlugin({
    // Keep the token in an env var (process.env, import.meta.env, etc. — depends on your bundler)
    token: 'your-databowl-api-token',
    endpoint: '/api/send-lead', // Optional proxy route (default: 'https://www.databowl.com/api/v4/leads')
    fieldMapping: {
      // your field name → Databowl field name
      firstName: 'first_name',
      email: 'email_address',
      phone: 'phone',
    },
    staticFields: {
      source: 'website',
      campaign: 'spring-launch',
    },
    bodyFormat: 'url-encoded', // default — set 'json' if your proxy expects JSON
    continueOnError: false, // default — a Databowl failure fails the whole submission
  }),
);
```

Or use the `FormBuilder` shorthand:

```tsx
const config = FormBuilder.create('lead')
  .useDatabowl({ token: 'your-databowl-api-token', fieldMapping: { firstName: 'first_name' } })
  .addField('firstName', (f) => f.type('text').label('First name').required())
  .addStep('main', ['firstName'])
  .build();
```

#### databowlAction

If you'd rather wire the submit action yourself — for example to combine it with other actions — `databowlAction(config)` returns a pre-configured HTTP submit action for use with `.submitAction()` instead of the full plugin. It accepts `token`, `endpoint`, and `bodyFormat`, and defaults to a `POST` to `https://www.databowl.com/api/v4/leads` with a url-encoded body, bearer auth, and a 15-second timeout.

```tsx

const config = FormBuilder.create('lead')
  .addField('firstName', (f) => f.type('text').label('First name').required())
  .addStep('main', ['firstName'])
  .submitAction('databowl', databowlAction({ token: 'your-databowl-api-token' }), 'onSubmit', {
    fieldMapping: { firstName: 'first_name' },
  })
  .build();
```

### recaptchaPlugin

Google reCAPTCHA v3 integration. Injects the script on form init and automatically adds a fresh token to every submission.

```tsx

pm.register(
  recaptchaPlugin({
    siteKey: 'your-recaptcha-site-key',
    action: 'submit', // Action name sent to Google (default: 'submit')
    tokenField: '_recaptchaToken', // Field name in submitted values (default: '_recaptchaToken')
    failMode: 'open', // 'open' (default): proceed tokenless | 'closed': fail the submit
  }),
);
```

What it does:

- `onFormInit` — Injects the reCAPTCHA v3 script into `<body>` (deduped, skips if already loaded)
- `transformValues` — Calls `grecaptcha.execute()` to get a fresh token and adds it to the form values
- `cleanup` — Removes the injected script (runs when you call `pm.unregister('recaptcha-v3')` or `pm.cleanup()` — the form does not call it automatically on unmount)

Token failures are non-blocking **by default** (`failMode: 'open'`): the plugin logs the error and the submission proceeds without a token — server-side verification is the backstop, and reCAPTCHA v3 is advisory scoring. Set `failMode: 'closed'` to fail the submit with a clear error instead (turnstile's default).

Your backend then verifies the token:

```tsx
// Server-side verification
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
  method: 'POST',
  body: new URLSearchParams({
    secret: process.env.RECAPTCHA_SECRET_KEY,
    response: values._recaptchaToken,
  }),
});
```

> This plugin replaces the old `useRecaptcha` hook (removed in 0.8). The hook only injected the script — this plugin also handles token generation automatically. The legacy `config.submit.recaptcha` path no longer auto-injects the script either: register this plugin (recommended) or add the script tag yourself.

### turnstilePlugin

[Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) integration — the Turnstile counterpart to `recaptchaPlugin`. Unlike reCAPTCHA v3, Turnstile is widget-based, so the plugin renders the widget into a container it manages, in `execution: 'execute'` mode (it only challenges at submit time), and attaches a fresh token to every submission.

```tsx

pm.register(
  turnstilePlugin({
    siteKey: 'your-turnstile-site-key',
    tokenField: '_captchaToken', // Field name in submitted values (default: '_captchaToken')
    appearance: 'interaction-only', // 'always' | 'execute' | 'interaction-only' (default)
    container: '#turnstile-slot', // Optional CSS selector for the widget container
    failMode: 'closed', // 'closed' (default): no token → submit fails | 'open': proceed tokenless
  }),
);
```

With the default `appearance: 'interaction-only'`, the widget stays invisible unless Cloudflare decides an interactive challenge is needed — the closest UX to reCAPTCHA v3. The default `_captchaToken` field name is also what the hosted submit backend expects, so if you use [HubForm](/docs/hub-form) with a captcha-enabled form, this plugin is wired up automatically.

The widget container is resolved in this order:

1. The explicit `container` selector, if provided and found.
2. An element matching `[data-saastro-turnstile]`.
3. A fixed, unobtrusive container the plugin creates at the bottom-right of the page, so an interactive challenge can still surface.

What it does:

- `onFormInit` — Injects the Turnstile script (deduped, skips if already loaded) and pre-renders the widget on a best-effort basis
- `transformValues` — Requests a fresh token on every submit (Turnstile tokens are single-use), waiting up to 20 seconds, and adds it to the form values
- `cleanup` — Removes the widget, the fallback container, and the injected script (runs when you call `pm.unregister('turnstile')` or `pm.cleanup()` — the form does not call it automatically on unmount)

Unlike `recaptchaPlugin`, token failures **fail closed by default**: if no token can be obtained (script blocked, timeout), the submit fails with a clear error — the server would reject a tokenless request anyway, and a local error is actionable. Set `failMode: 'open'` to restore the old proceed-without-token behavior. Your backend then verifies the token:

```tsx
// Server-side verification
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  body: new URLSearchParams({
    secret: process.env.TURNSTILE_SECRET_KEY,
    response: values._captchaToken,
  }),
});
```

### hubPlugin

Sends every submission to the [hosted Saastro Hub backend](/docs/hub-form) — for **any** form, including ones built entirely in code with `FormBuilder`. You get hosted storage, notifications, and the presigned file-upload pipeline without `<HubForm>` or the visual builder:

```tsx

const pm = new PluginManager();
pm.register(
  hubPlugin({
    siteId: 'my-site',
    formSlug: 'contact',
    hubUrl: 'https://submit.saastro.io/v1', // default — override for self-hosted
    continueOnError: false, // default: a Hub failure fails the submit
  }),
);

const config = FormBuilder.create('contact')
  .usePlugins(pm)
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['email'])
  .build();
```

What it does:

- `transformConfig` — Injects a `hub-submit` action with an `onSubmit` trigger, powered by the same `createHubFormSubmit` pipeline `<HubForm>` uses: `file` fields upload via presigned URLs, and the `_captchaToken`/`_hp` conventions apply
- Composable: it's a regular submit action, so it coexists with your other actions (webhooks, analytics) and respects `submitExecution` (mode, `stopOnFirstError`, `globalTimeout`)

Combine with `turnstilePlugin` for captcha-protected hosted submissions.

### localeDetectorPlugin

Activates the form's `locale` from the browser language (`navigator.language`) when the config doesn't set one explicitly and a matching translation overlay exists:

```tsx

pm.register(localeDetectorPlugin()); // matches config.i18n.translations keys
pm.register(localeDetectorPlugin({ fallback: 'en' })); // when nothing matches
pm.register(localeDetectorPlugin({ supported: ['en', 'es', 'fr'] })); // explicit allowlist
```

An explicit `config.locale` always wins. See the [Internationalization guide](/docs/i18n) for the overlay system. Note this picks the **per-form** locale — the package-level default strings are flipped separately with `setDefaultMessages`.

---

## Creating Your Own Plugin

Use `definePlugin()` for type safety:

```tsx

  name: 'my-plugin',
  version: '1.0.0',
  description: 'Does something useful',

  // Optional: initialize with options
  options: { apiKey: '...' },
  init(options) {
    console.log('Plugin initialized with:', options);
  },

  // Lifecycle hooks (all optional)
  onFormInit(config) {
    /* ... */
  },
  onFieldChange(fieldName, value, allValues) {
    /* ... */
  },
  onStepChange(stepId, values) {
    /* ... */
  },
  onBeforeSubmit(values) {
    /* ... */
  },
  onAfterSubmit(values, response) {
    /* ... */
  },
  onError(error, values) {
    /* ... */
  },

  // Transformers (all optional)
  transformConfig(config) {
    return config;
  },
  transformValues(values) {
    return values;
  },

  // Custom fields (optional)
  registerFields() {
    return { myField: MyFieldComponent };
  },

  // Custom validators (optional)
  validators: {
    myRule: (value, ctx) => (value ? true : 'Required'),
  },

  // Cleanup (optional)
  cleanup() {
    console.log('Plugin cleaned up');
  },
});
```

---

## API Reference

### PluginManager

```tsx
```

| Method                       | Description                                      |
| ---------------------------- | ------------------------------------------------ |
| `register(plugin)`           | Register a plugin (throws if name already taken) |
| `unregister(name)`           | Remove a plugin (calls cleanup)                  |
| `getPlugin(name)`            | Get a registered plugin                          |
| `getAllPlugins()`            | Get all registered plugins                       |
| `getCustomField(type)`       | Get a custom field renderer                      |
| `hasCustomField(type)`       | Check if a custom field type exists              |
| `getValidator(name)`         | Get a custom validator                           |
| `executeHook(hook, ...args)` | Execute a lifecycle hook on all plugins          |
| `transformConfig(config)`    | Run all config transformers                      |
| `transformValues(values)`    | Run all value transformers                       |
| `cleanup()`                  | Clean up all plugins and clear registries        |
| `getStats()`                 | Get plugin system statistics                     |

### FormPlugin Interface

```tsx
interface FormPlugin {
  name: string;
  version: string;
  description?: string;
  options?: Record<string, unknown>;
  init?: (options?: Record<string, unknown>) => void;
  cleanup?: () => void;

  // Lifecycle hooks
  onFormInit?: (config: FormConfig) => void;
  onFieldChange?: (fieldName: string, value: unknown, allValues: Record<string, unknown>) => void;
  onStepChange?: (stepId: string, values: Record<string, unknown>) => void;
  onBeforeSubmit?: (values: Record<string, unknown>) => void | Promise<void>;
  onAfterSubmit?: (values: Record<string, unknown>, response: unknown) => void;
  onError?: (error: Error, values?: Record<string, unknown>) => void;

  // Transformers
  transformConfig?: (config: FormConfig) => FormConfig;
  transformValues?: (
    values: Record<string, unknown>,
  ) => Record<string, unknown> | Promise<Record<string, unknown>>;

  // Extensions
  registerFields?: () => Record<string, FieldRenderer>;
  validators?: Record<
    string,
    (value: unknown, context: ValidationContext) => boolean | string | Promise<boolean | string>
  >;
}
```

### ValidationContext

```tsx
interface ValidationContext {
  fieldName: string;
  allValues: Record<string, unknown>;
  abortSignal?: AbortSignal;
}
```

At the current runtime call site, `allValues` is always an empty object and `abortSignal` is never set — see [Custom Validators](#custom-validators).

### definePlugin()

```tsx

const plugin = definePlugin({ name: '...', version: '...' /* ... */ });
```

A simple identity function that provides type checking for plugin objects.

### globalPluginManager

A shared `PluginManager` instance exported for convenience. You can use it or create your own instances.

```tsx

globalPluginManager.register(myPlugin);
```
