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);
}}
/>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),
});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;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
- DropZone – Drag a CSV, click to upload, or paste tabular data
- Auto-mapping – Headers are matched to form paths using labels, field names, and aliases
- Row shaping – Raw values are coerced based on field metadata (numbers, booleans, selects)
- Grid preview – Users can edit inline using the same Cubitt field components
- Validation – Zod runs on every row; errors appear inline
- 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
| 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
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
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
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 };
}
}}