Skip to content

Commit b1fa73b

Browse files
authored
Merge pull request #355 from better-stack-ai/feat/issue-338-after-v6
feat(form-builder): add conditional fields, uploads and CSV exports
2 parents bbe250a + e025d16 commit b1fa73b

50 files changed

Lines changed: 2568 additions & 180 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/content/docs/plugins/form-builder.mdx‎

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ The form builder provides a drag-and-drop interface with:
216216
| Date Picker | Date selection | `type: "string", format: "date-time"` |
217217
| Phone | Phone number input | `type: "string", fieldType: "phone"` |
218218
| URL | Website URL input | `type: "string", format: "uri"` |
219+
| File Upload | Standalone upload using your callback | `type: "string", fieldType: "file"` |
219220

220221
### Field Properties
221222

@@ -231,6 +232,73 @@ Each field can be configured with:
231232
| Min/Max | Minimum and maximum values (numbers) or length (strings) |
232233
| Options | For select, radio, and checkbox groups |
233234
| Default Value | Pre-filled value for the field |
235+
| Visibility | One `showIf` condition against another field |
236+
237+
### Conditional fields and steps
238+
239+
In a field's edit dialog, use **Visibility** to choose a source field, operator,
240+
and comparison value. For a multi-step form, use the active step's visibility
241+
editor below its tabs. Step conditions can use fields from earlier steps.
242+
Choose **Always show** to remove a condition.
243+
244+
`showIf` is stored on a property or step as `{ field, operator, value }`.
245+
`field` names a sibling field (a top-level field for step rules).
246+
247+
| Operator | Comparison |
248+
|----------|------------|
249+
| `equals` | Strict equality; `1` differs from `"1"` |
250+
| `notEquals` | Strict inequality |
251+
| `in` | Source value is an element of the `value` array |
252+
| `isTruthy` | JavaScript truthiness; no comparison value is needed |
253+
254+
The editor accepts text, numbers, and booleans. Quote a number or boolean to
255+
compare it as text. For `in`, enter one comparison value per line.
256+
257+
```ts
258+
import { z } from "zod"
259+
import { zodToFormSchema } from "@btst/plugin-form-builder/client"
260+
261+
const schema = z.object({
262+
business: z.boolean(),
263+
company: z.string().min(1).meta({
264+
showIf: { field: "business", operator: "isTruthy" },
265+
}),
266+
})
267+
const jsonSchema = zodToFormSchema(schema)
268+
```
269+
270+
Both `FormRenderer` and `SteppedAutoForm` hide non-matching fields and skip hidden
271+
steps. The shared `formSchemaToZod` validator bypasses their required constraints
272+
and removes stale values, including nested object and array-item fields, before
273+
persistence. Visible fields retain their normal validation. Submission hooks
274+
receive validated data; any replacement data is validated again before storage.
275+
276+
### Standalone file uploads
277+
278+
Drag **File Upload** from the palette. Configure an upload callback on
279+
`FormRenderer`, or set `overrides.formBuilder.onUpload` on `StackProvider`:
280+
281+
```tsx
282+
<FormRenderer
283+
slug="application"
284+
onUpload={async (file) => {
285+
const body = new FormData()
286+
body.append("file", file)
287+
const response = await fetch("/api/files", { method: "POST", body })
288+
if (!response.ok) throw new Error("Upload failed")
289+
const result = await response.json()
290+
return result.url
291+
}}
292+
/>
293+
```
294+
295+
The callback receives a `File` and returns a non-empty URL string, including
296+
relative URLs. Only that string is submitted. Your endpoint owns file validation,
297+
storage, access control, and cleanup; the Media plugin is optional. The renderer
298+
disables submission during upload and displays upload failures with a retry path.
299+
Without a callback, the file control is disabled with a configuration message.
300+
For standalone AutoForm, use `fieldConfig.attachment.onUpload`. Builder previews
301+
can use `fieldComponents.file` to provide an uploader.
234302

235303
## Public Form Rendering
236304

@@ -276,6 +344,7 @@ export default function FormDemoPage({ params }: { params: { slug: string } }) {
276344
| `slug` | `string` | Form slug to fetch and render |
277345
| `onSuccess` | `(submission) => void` | Callback after successful submission (submission.form has success info) |
278346
| `onError` | `(error) => void` | Callback when submission fails |
347+
| `onUpload` | `(file: File) => Promise<string>` | Standalone file uploader; overrides the provider callback |
279348
| `LoadingComponent` | `ComponentType` | Custom loading state |
280349
| `ErrorComponent` | `ComponentType<{ error: Error }>` | Custom error state |
281350
| `submitButtonText` | `string` | Custom submit button text |
@@ -306,6 +375,7 @@ Access form data in your frontend using the provided hooks:
306375
| `useSubmissions(formId)` | List non-sensitive submission metadata and authoritative form facts | `{ form, submissions, total, isLoading }` |
307376
| `useSubmission(formId, submissionId)` | Get one record-authorized submission with its data, IP address, and user agent | `{ submission, isLoading, error }` |
308377
| `useDeleteSubmission(formId)` | Delete submission | React Query mutation |
378+
| `useExportSubmissions()` | Export all submissions for a form ID | Mutation returning `{ csv, filename }` |
309379

310380
`useSubmissions` returns `form` as the intentionally minimal
311381
`SubmissionListFormContext`: `{ id, name, createdBy? }`. Submission access does
@@ -387,6 +457,10 @@ export const authorization = defineAuthorization({
387457
identity?.role === "admin" || identity?.id === facts.ownerId,
388458
),
389459
forms.submission.create.allow(),
460+
forms.submission.export.when(({ identity, facts }) =>
461+
identity !== null &&
462+
(identity.role === "admin" || identity.id === facts.ownerId),
463+
),
390464
forms.submission.read.when(({ identity, facts }) =>
391465
identity?.role === "admin" || identity?.id === facts.ownerId,
392466
),
@@ -468,6 +542,30 @@ transactional database adapter. The CLI supports Form Builder with Prisma,
468542
Drizzle, and Kysely and rejects its current memory and MongoDB scaffold
469543
configurations.
470544

545+
## CSV export
546+
547+
The submissions page offers **Export CSV** when `submission.export` is granted.
548+
This is a separate permission for all payloads of the selected form; permission
549+
to read one submission or collection metadata does not grant export access.
550+
Facts include the server-resolved `formId`, `formExists`, and `ownerId`.
551+
552+
The operation exports every submission, independent of admin pagination. Columns
553+
contain `id`, `submittedAt`, and `data.<field>` values. Deleted fields remain in
554+
the export when older submissions contain them. Nested values are JSON strings;
555+
cells are quoted and formula-like values are prefixed with an apostrophe for
556+
spreadsheet safety. Source reads are paged and CSV rows are serialized without
557+
building a full cell matrix. An export supports up to 10,000 submissions,
558+
1,000 distinct data columns, one million data cells, and 10 MiB each of stored
559+
JSON and CSV output. Larger exports fail with HTTP 413 and
560+
`FORM_EXPORT_TOO_LARGE`; they require an application-managed streaming or
561+
offline export. No partial CSV is downloaded when a limit is exceeded.
562+
563+
```ts
564+
const result = await stack.forRequest(request).operations.formBuilder
565+
.exportSubmissions({ formId })
566+
// { csv: string, filename: string }
567+
```
568+
471569
## Backend Hooks
472570

473571
Authorization runs before lifecycle hooks. Use hooks for domain behavior such
@@ -516,6 +614,7 @@ The Form Builder plugin exposes these REST endpoints:
516614
| `/form-builder/forms/:id` | DELETE | Delete a form |
517615
| `/form-builder/forms/:slug/submit` | POST | Submit form data |
518616
| `/form-builder/forms/:formId/submissions` | GET | List non-sensitive submission metadata for a form |
617+
| `/form-builder/forms/:formId/submissions/export` | POST | Export all submissions as `{ csv, filename }`; requires `submission.export` |
519618
| `/form-builder/forms/:formId/submissions/:subId` | GET | Get a record-authorized submission and its contents |
520619
| `/form-builder/forms/:formId/submissions/:subId` | DELETE | Delete a submission |
521620

@@ -555,20 +654,17 @@ The Form Builder supports multi-step forms through JSON Schema's `allOf` structu
555654
```json
556655
{
557656
"type": "object",
558-
"allOf": [
559-
{
560-
"title": "Step 1: Personal Info",
561-
"properties": {
562-
"name": { "type": "string", "label": "Full Name" },
563-
"email": { "type": "string", "format": "email" }
564-
}
565-
},
657+
"properties": {
658+
"business": { "type": "boolean", "stepGroup": 0 },
659+
"company": { "type": "string", "stepGroup": 1 }
660+
},
661+
"required": ["business", "company"],
662+
"steps": [
663+
{ "id": "personal", "title": "Personal Info" },
566664
{
567-
"title": "Step 2: Details",
568-
"properties": {
569-
"company": { "type": "string" },
570-
"message": { "type": "string", "fieldType": "textarea" }
571-
}
665+
"id": "company",
666+
"title": "Company Details",
667+
"showIf": { "field": "business", "operator": "isTruthy" }
572668
}
573669
]
574670
}

‎e2e/tests/smoke.form-builder.spec.ts‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,3 +771,174 @@ test.describe("Form Builder - Public Form Submission", () => {
771771
);
772772
});
773773
});
774+
775+
test.describe("Conditional Form Builder", () => {
776+
test.use({ extraHTTPHeaders: mockAuthHeaders() });
777+
test.beforeEach(async ({ context }) => {
778+
await setMockAuthCookie(context);
779+
});
780+
781+
async function createConditionalForm(
782+
request: import("@playwright/test").APIRequestContext,
783+
) {
784+
const slug = `conditional-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
785+
const response = await request.post("/api/data/forms", {
786+
data: {
787+
name: "Conditional application",
788+
slug,
789+
status: "active",
790+
schema: JSON.stringify({
791+
type: "object",
792+
properties: {
793+
business: {
794+
type: "boolean",
795+
label: "Business applicant",
796+
default: false,
797+
stepGroup: 0,
798+
},
799+
company: {
800+
type: "string",
801+
label: "Company name",
802+
minLength: 1,
803+
stepGroup: 0,
804+
showIf: { field: "business", operator: "isTruthy" },
805+
},
806+
attachment: {
807+
type: "string",
808+
label: "Attachment",
809+
fieldType: "file",
810+
stepGroup: 1,
811+
},
812+
notes: { type: "string", label: "Notes", stepGroup: 2 },
813+
},
814+
required: ["business", "company", "attachment"],
815+
steps: [
816+
{ id: "basics", title: "Basics" },
817+
{
818+
id: "documents",
819+
title: "Documents",
820+
showIf: { field: "business", operator: "isTruthy" },
821+
},
822+
{ id: "review", title: "Review" },
823+
],
824+
}),
825+
},
826+
});
827+
expect(response.ok()).toBe(true);
828+
return { ...(await response.json()), slug };
829+
}
830+
831+
test("hides fields, skips steps, strips stale values and exports a CSV download", async ({
832+
page,
833+
request,
834+
}) => {
835+
const form = await createConditionalForm(request);
836+
try {
837+
await page.goto(`/form-demo/${form.slug}`);
838+
await expect(page.getByTestId("form-renderer")).toBeVisible();
839+
await expect(
840+
page.getByRole("textbox", { name: "Company name" }),
841+
).toHaveCount(0);
842+
await expect(page.getByText("Documents", { exact: true })).toHaveCount(0);
843+
await page.getByRole("checkbox", { name: "Business applicant" }).check();
844+
await expect(
845+
page.getByRole("textbox", { name: "Company name" }),
846+
).toBeVisible();
847+
await page
848+
.getByRole("checkbox", { name: "Business applicant" })
849+
.uncheck();
850+
await page.getByRole("button", { name: "Next", exact: true }).click();
851+
await expect(
852+
page.getByRole("heading", { name: "Review", exact: true }),
853+
).toBeVisible();
854+
await page.getByRole("button", { name: "Submit", exact: true }).click();
855+
await expect(
856+
page.getByRole("heading", { name: "Form Submitted" }),
857+
).toBeVisible();
858+
const stale = await request.post(`/api/data/forms/${form.slug}/submit`, {
859+
data: {
860+
data: {
861+
business: false,
862+
company: { invalid: true },
863+
attachment: "stale-file",
864+
},
865+
},
866+
});
867+
expect(stale.ok()).toBe(true);
868+
expect(JSON.parse((await stale.json()).data)).toEqual({
869+
business: false,
870+
});
871+
await page.goto(`/pages/forms/${form.id}/submissions`);
872+
const downloadPromise = page.waitForEvent("download");
873+
await page
874+
.getByRole("button", { name: "Export CSV", exact: true })
875+
.click();
876+
const download = await downloadPromise;
877+
expect(download.suggestedFilename()).toBe(`${form.slug}-submissions.csv`);
878+
const exported = await request.post(
879+
`/api/data/forms/${form.id}/submissions/export`,
880+
);
881+
expect(exported.ok()).toBe(true);
882+
const { csv } = await exported.json();
883+
expect(csv).toContain(
884+
'"data.business","data.company","data.attachment","data.notes"',
885+
);
886+
expect(csv).not.toContain("stale-file");
887+
} finally {
888+
await request.delete(`/api/data/forms/${form.id}`);
889+
}
890+
});
891+
892+
test("edits field and step visibility in the visual builder and retains file fields", async ({
893+
page,
894+
request,
895+
}) => {
896+
const form = await createConditionalForm(request);
897+
try {
898+
await page.goto(`/pages/forms/${form.id}/edit`);
899+
await expect(
900+
getPalette(page).getByText("File Upload", { exact: true }),
901+
).toBeVisible();
902+
const company = getCanvas(page)
903+
.locator('[class*="rounded-lg"]')
904+
.filter({ hasText: "Company name" })
905+
.first();
906+
await company.hover();
907+
await company.locator("button").first().click();
908+
const dialog = page.getByRole("dialog");
909+
await expect(
910+
dialog.getByText("Visibility", { exact: true }),
911+
).toBeVisible();
912+
await dialog.getByLabel("Condition", { exact: true }).click();
913+
await page.getByRole("option", { name: "Equals", exact: true }).click();
914+
await dialog.getByLabel("Value", { exact: true }).fill("true");
915+
await dialog
916+
.getByRole("button", { name: "Save Changes", exact: true })
917+
.click();
918+
await page
919+
.getByRole("button", { name: "Documents", exact: true })
920+
.click();
921+
await expect(
922+
page.getByText("Documents visibility", { exact: true }),
923+
).toBeVisible();
924+
await page.getByRole("button", { name: "Save", exact: true }).click();
925+
await expect(
926+
page.getByText("Form updated successfully", { exact: true }),
927+
).toBeVisible();
928+
const saved = await request.get(`/api/data/forms/id/${form.id}`);
929+
const schema = JSON.parse((await saved.json()).schema);
930+
expect(schema.properties.company.showIf).toEqual({
931+
field: "business",
932+
operator: "equals",
933+
value: true,
934+
});
935+
expect(schema.steps[1].showIf).toEqual({
936+
field: "business",
937+
operator: "isTruthy",
938+
});
939+
expect(schema.properties.attachment.fieldType).toBe("file");
940+
} finally {
941+
await request.delete(`/api/data/forms/${form.id}`);
942+
}
943+
});
944+
});

0 commit comments

Comments
 (0)