Bulk

Spreadsheet-style ingestion with CSV import, header mapping, validation, and grid editing.

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.

Need a single-record form?

For single-record forms, see FormBuilder.Single instead.

Quick Start

Define the schema

Model your payload with Zod and export it for reuse.

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

Compose formDefs

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

import type { FormDefs } from "@tilt-legal/cubitt-components/form-builder";
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>;

Render and handle submission

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

import { FormBuilder } from "@tilt-legal/cubitt-components/form-builder";
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 };
      }}
    />
  );
}

Examples

Basic Import

<FormBuilder.Bulk
  schema={memberSchema}
  formDefs={formDefs}
  template={{ filename: "members.csv" }}
  onSubmit={async ({ rows }) => {
    await importMembers(rows);
  }}
/>

External Submit Button

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

  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 components
  5. Validation – Zod runs on every row; errors appear inline
  6. Submission – You receive { rows } and can return per-row errors

Template Downloads

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

<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

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

<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

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:

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

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

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

Control rows externally for syncing with server state:

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

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

Parse Options

Configure CSV parsing behaviour:

<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

PropTypeDefaultDescription
schemaz.ZodTypeAnyZod schema applied to every imported row (required)
formDefsFormDefs<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)
acceptstring".csv"File types accepted by the dropzone
validateOnEditbooleantrueWhen false, delays revalidation until explicit request
rowsT[]Controlled mode: provide external row data
onRowsChange(rows: T[]) => voidCallback when internal rows update
disabledbooleanfalseDisables built-in ingest, grid editing, mapping, and default toolbar actions
classNamestringCSS classes applied to the root form element
idstringForm ID for external submit buttons
submitRefRef<() => void>Imperative ref for triggering submission
toolbarfalse | RenderToolbarToolbar control: omit for default, false to hide, or custom render
enableExportInvalidCsvbooleantrueToggle the export invalid CSV button

Toolbar Hook

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

PropertyTypeDescription
actions.reviewMapping() => voidOpen the column mapping dialog
actions.downloadTemplate() => voidDownload the CSV template
actions.validateAll() => voidRun validation on all rows
actions.submit() => voidTrigger form submission
actions.exportInvalidCsv() => voidExport rows with validation errors
render(slot) => ReactNodePass to toolbar prop

Toolbar Render Slot

The render function receives a slot object:

PropertyTypeDescription
actionsCreateBulkFormToolbarActionsThe same toolbar actions exposed on the hook handle
defaultToolbarReactNodeThe default Cubitt toolbar UI, useful if you want to wrap or extend it
disabledbooleanMirrors the disabled prop so custom toolbars can lock their own controls

Error Handling

Return a BulkSubmitFailure from onSubmit to surface errors:

import type { FormDefs } from "@tilt-legal/cubitt-components/form-builder";

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:

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

On this page