

`FormBuilder.Bulk` reuses the same schema and field definitions as `FormBuilder.Single`, but optimises for importing many rows at once. It handles CSV ingestion, header mapping, inline editing, and validation before submission.

<Callout type="info" title="Need a single-record form?">
  For single-record forms, see [FormBuilder.Single](/composites/form-builder/single) instead.
</Callout>

## Quick Start [#quick-start]

<Steps>
  <Step>
    ### Define the schema [#define-the-schema]

    Model your payload with Zod and export it for reuse.

    ```tsx
    import { z } from "zod";

    export const memberSchema = z.object({
      firstName: z.string().min(1, "Required"),
      lastName: z.string().min(1, "Required"),
      email: z.string().email("Invalid email"),
      active: z.boolean().default(true),
    });

    export type Member = z.infer<typeof memberSchema>;
    ```
  </Step>

  <Step>
    ### Compose formDefs [#compose-formdefs]

    Build a `formDefs` array. The bulk form uses this for grid columns, CSV templates, and value coercion.

    ```tsx
    import type { FormDefs } from "@tilt-legal/cubitt-components/composites";
    import type { Member } from "./schema";

    export const formDefs = [
      { kind: "field", name: "firstName", label: "First name", component: "text", size: "half" },
      { kind: "field", name: "lastName", label: "Last name", component: "text", size: "half" },
      { kind: "field", name: "email", label: "Email", component: "email", size: "full" },
      { kind: "field", name: "active", label: "Active", component: "switch", size: "full" },
    ] as const satisfies FormDefs<Member>;
    ```
  </Step>

  <Step>
    ### Render and handle submission [#render-and-handle-submission]

    Render `FormBuilder.Bulk` with your schema and definitions. Return a `BulkSubmitFailure` when the server reports issues.

    ```tsx
    import { FormBuilder } from "@tilt-legal/cubitt-components/composites";
    import { memberSchema } from "./schema";
    import { formDefs } from "./form-defs";

    export function MemberImport() {
      return (
        <FormBuilder.Bulk
          schema={memberSchema}
          formDefs={formDefs}
          template={{ filename: "members.csv" }}
          onSubmit={async ({ rows }) => {
            const res = await importMembers(rows);
            if (res.error) return { error: res.error };
          }}
        />
      );
    }
    ```
  </Step>
</Steps>

## Examples [#examples]

### Basic Import [#basic-import]

<Tabs items="[&#x22;Usage&#x22;, &#x22;Schema&#x22;, &#x22;formDefs&#x22;]">
  <Tab>
    ```tsx
    <FormBuilder.Bulk
      schema={memberSchema}
      formDefs={formDefs}
      template={{ filename: "members.csv" }}
      onSubmit={async ({ rows }) => {
        await importMembers(rows);
      }}
    />
    ```
  </Tab>

  <Tab>
    ```tsx
    const memberSchema = z.object({
      firstName: z.string().min(1, "Required"),
      lastName: z.string().min(1, "Required"),
      email: z.string().email(),
      active: z.boolean().default(true),
    });
    ```
  </Tab>

  <Tab>
    ```tsx
    const formDefs = [
      { kind: "field", name: "firstName", label: "First name", component: "text", size: "half" },
      { kind: "field", name: "lastName", label: "Last name", component: "text", size: "half" },
      { kind: "field", name: "email", label: "Email", component: "email", size: "full" },
      { kind: "field", name: "active", label: "Active", component: "switch", size: "full" },
    ] as const satisfies FormDefs;
    ```
  </Tab>
</Tabs>

### External Submit Button [#external-submit-button]

```tsx
const formId = "member-import";

<FormBuilder.Bulk
  id={formId}
  schema={memberSchema}
  formDefs={formDefs}
  onSubmit={async ({ rows }) => {
    await importMembers(rows);
  }}
/>

<Button type="submit" form={formId}>
  Import Members
</Button>
```

## CSV Ingestion Lifecycle [#csv-ingestion-lifecycle]

1. **DropZone** – Drag a CSV, click to upload, or paste tabular data
2. **Auto-mapping** – Headers are matched to form paths using labels, field names, and aliases
3. **Row shaping** – Raw values are coerced based on field metadata (numbers, booleans, selects)
4. **Grid preview** – Users can edit inline using the same Cubitt field primitives
5. **Validation** – Zod runs on every row; errors appear inline
6. **Submission** – You receive `{ rows }` and can return per-row errors

## Template Downloads [#template-downloads]

By default, the bulk form generates a template CSV using field labels from your `formDefs`:

```tsx
<FormBuilder.Bulk
  schema={memberSchema}
  formDefs={formDefs}
  template={{
    filename: "members-template.csv",
    exampleRow: {
      firstName: "Jane",
      lastName: "Doe",
      email: "jane@example.com",
      active: true,
    },
  }}
  onSubmit={handleSubmit}
/>
```

## Header Mapping Aliases [#header-mapping-aliases]

The importer auto-maps headers by matching field paths and labels. Add aliases for synonyms:

```tsx
<FormBuilder.Bulk
  schema={memberSchema}
  formDefs={formDefs}
  mapping={{
    headerAliases: {
      firstName: ["first name", "given_name", "first"],
      lastName: ["last name", "surname", "family_name"],
      active: ["is active", "enabled", "status"],
    },
  }}
  onSubmit={handleSubmit}
/>
```

## Toolbar [#toolbar]

When the grid contains rows, a toolbar appears with default actions:

* **Mapping** – Review/adjust column mapping
* **Template** – Download CSV template
* **Validate** – Run validation on all rows
* **Submit** – Submit the form (when no external trigger)
* **Export Invalid** – Export rows with errors

Control the toolbar:

```tsx
// Hide toolbar entirely
<FormBuilder.Bulk toolbar={false} ... />

// Use default toolbar (default behaviour)
<FormBuilder.Bulk ... />

// Custom toolbar
const toolbar = FormBuilder.Bulk.useToolbar();

<FormBuilder.Bulk toolbar={toolbar.render} ... />
```

### Custom Toolbar Layout [#custom-toolbar-layout]

Use `FormBuilder.Bulk.useToolbar()` to build custom UI:

```tsx
function CustomBulkImport() {
  const toolbar = FormBuilder.Bulk.useToolbar(({ actions, disabled }) => (
    <div className="flex gap-2">
      <Button disabled={disabled} variant="outline" onClick={actions.reviewMapping}>
        Edit Mapping
      </Button>
      <Button disabled={disabled} variant="outline" onClick={actions.downloadTemplate}>
        Download Template
      </Button>
      <Button disabled={disabled} variant="outline" onClick={actions.validateAll}>
        Validate All
      </Button>
      <Button disabled={disabled} onClick={actions.submit}>
        Import
      </Button>
    </div>
  ));

  return (
    <FormBuilder.Bulk
      schema={memberSchema}
      formDefs={formDefs}
      toolbar={toolbar.render}
      onSubmit={handleSubmit}
    />
  );
}
```

## Controlled Mode [#controlled-mode]

Control rows externally for syncing with server state:

```tsx
const [rows, setRows] = useState<Member[]>([]);

<FormBuilder.Bulk
  schema={memberSchema}
  formDefs={formDefs}
  rows={rows}
  onRowsChange={setRows}
  onSubmit={async ({ rows }) => {
    await syncMembers(rows);
  }}
/>
```

## Parse Options [#parse-options]

Configure CSV parsing behaviour:

```tsx
<FormBuilder.Bulk
  schema={memberSchema}
  formDefs={formDefs}
  parseOptions={{
    delimiter: "auto",      // "," | ";" | "\t" | "|" | "auto"
    headerRow: 0,           // Row index for headers
    maxRows: 10000,         // Maximum rows to import
    localeDecimal: ".",     // Decimal separator for numbers
  }}
  onSubmit={handleSubmit}
/>
```

## API Reference [#api-reference]

| Prop                     | Type                                                   | Default  | Description                                                                                              |
| ------------------------ | ------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------- |
| `schema`                 | `z.ZodTypeAny`                                         | —        | Zod schema applied to every imported row (required)                                                      |
| `formDefs`               | `FormDefs<T>`                                          | —        | Shared definitions for columns, labels, and grid editors (required)                                      |
| `onSubmit`               | `(opts: { rows: T[] }) => BulkSubmitResult<T>`         | —        | Async handler receiving `{ rows }`. Return nothing on success or BulkSubmitFailure for errors (required) |
| `parseOptions`           | `{ delimiter?, headerRow?, maxRows?, localeDecimal? }` | —        | CSV parsing configuration                                                                                |
| `mapping`                | `{ headerAliases?: Record<string, string[]> }`         | —        | Header alias configuration for auto-mapping columns                                                      |
| `template`               | `{ filename?, exampleRow? }`                           | —        | Template download configuration (filename, example row)                                                  |
| `accept`                 | `string`                                               | `".csv"` | File types accepted by the dropzone                                                                      |
| `validateOnEdit`         | `boolean`                                              | `true`   | When false, delays revalidation until explicit request                                                   |
| `rows`                   | `T[]`                                                  | —        | Controlled mode: provide external row data                                                               |
| `onRowsChange`           | `(rows: T[]) => void`                                  | —        | Callback when internal rows update                                                                       |
| `disabled`               | `boolean`                                              | `false`  | Disables built-in ingest, grid editing, mapping, and default toolbar actions                             |
| `className`              | `string`                                               | —        | CSS classes applied to the root form element                                                             |
| `id`                     | `string`                                               | —        | Form ID for external submit buttons                                                                      |
| `submitRef`              | `Ref<() => void>`                                      | —        | Imperative ref for triggering submission                                                                 |
| `toolbar`                | `false \| RenderToolbar`                               | —        | Toolbar control: omit for default, false to hide, or custom render                                       |
| `enableExportInvalidCsv` | `boolean`                                              | `true`   | Toggle the export invalid CSV button                                                                     |

## Toolbar Hook [#toolbar-hook]

`FormBuilder.Bulk.useToolbar()` returns a handle for external control:

| Property                   | Type                  | Description                        |
| -------------------------- | --------------------- | ---------------------------------- |
| `actions.reviewMapping`    | `() => void`          | Open the column mapping dialog     |
| `actions.downloadTemplate` | `() => void`          | Download the CSV template          |
| `actions.validateAll`      | `() => void`          | Run validation on all rows         |
| `actions.submit`           | `() => void`          | Trigger form submission            |
| `actions.exportInvalidCsv` | `() => void`          | Export rows with validation errors |
| `render`                   | `(slot) => ReactNode` | Pass to `toolbar` prop             |

## Toolbar Render Slot [#toolbar-render-slot]

The `render` function receives a slot object:

| Property         | Type                           | Description                                                                |
| ---------------- | ------------------------------ | -------------------------------------------------------------------------- |
| `actions`        | `CreateBulkFormToolbarActions` | The same toolbar actions exposed on the hook handle                        |
| `defaultToolbar` | `ReactNode`                    | The default Cubitt toolbar UI, useful if you want to wrap or extend it     |
| `disabled`       | `boolean`                      | Mirrors the `disabled` prop so custom toolbars can lock their own controls |

## Error Handling [#error-handling]

Return a `BulkSubmitFailure` from `onSubmit` to surface errors:

```tsx
import type { FormDefs } from "@tilt-legal/cubitt-components/composites";

type BulkSubmitFailure = {
  // ZodError from server validation
  error?: ZodError;
  // Form-level error messages
  formErrors?: string[];
  // Per-row errors
  rows?: Array<{
    rowIndex: number;
    fieldErrors: Record<string, string[]>;
    rowErrors: string[];
  }>;
};
```

Example server response handling:

```tsx
onSubmit={async ({ rows }) => {
  const res = await fetch("/api/import", {
    method: "POST",
    body: JSON.stringify({ rows }),
  });
  const json = await res.json();

  if (json.status === "error") {
    // Return ZodError for automatic field mapping
    if (json.error?.issues) {
      return { error: new ZodError(json.error.issues) };
    }
    // Or return explicit row errors
    return { rows: json.rowErrors };
  }
}}
```
