@10x-media/form-builder 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +1434 -0
- package/dist/constants-B-BUfetP.d.ts +288 -0
- package/dist/exports/client.d.ts +21 -0
- package/dist/exports/client.js +320 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/i18n.d.ts +138 -0
- package/dist/exports/i18n.js +3 -0
- package/dist/exports/react.d.ts +550 -0
- package/dist/exports/react.js +1564 -0
- package/dist/exports/react.js.map +1 -0
- package/dist/exports/rsc.d.ts +16 -0
- package/dist/exports/rsc.js +55 -0
- package/dist/exports/rsc.js.map +1 -0
- package/dist/exports/types.d.ts +5 -0
- package/dist/exports/types.js +1 -0
- package/dist/fieldTypes-B8jkNRob.d.ts +188 -0
- package/dist/fieldTypes-CzhhJXjg.js +88 -0
- package/dist/fieldTypes-CzhhJXjg.js.map +1 -0
- package/dist/index-DfFYGbVF.d.ts +481 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1921 -0
- package/dist/index.js.map +1 -0
- package/dist/keys-N5xGiUsh.js +132 -0
- package/dist/keys-N5xGiUsh.js.map +1 -0
- package/dist/registry-CoCyhtvB.js +282 -0
- package/dist/registry-CoCyhtvB.js.map +1 -0
- package/dist/resolver-OeQyVH2N.js +665 -0
- package/dist/resolver-OeQyVH2N.js.map +1 -0
- package/dist/translations-edMqq_2e.js +152 -0
- package/dist/translations-edMqq_2e.js.map +1 -0
- package/dist/types-DsJ_6kGJ.d.ts +23 -0
- package/package.json +125 -0
- package/styles/form-builder.css +108 -0
package/README.md
ADDED
|
@@ -0,0 +1,1434 @@
|
|
|
1
|
+
# @10x-media/form-builder
|
|
2
|
+
|
|
3
|
+
An end-to-end forms platform for Payload v3: author, validate, render, collect, aggregate, and act on forms. Built to be the default forms solution for Payload projects, simple by default for non-technical editors, with real depth for power users and developers, and 100% native to Payload.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@10x-media/form-builder)
|
|
6
|
+
|
|
7
|
+
Part of the [@10x-media Payload plugins](https://github.com/10x-media/payload-plugins) collection. In beta: published under the `beta` dist-tag until a stable 1.0.
|
|
8
|
+
|
|
9
|
+
> Status: beta, feature-complete v1. Published under the `beta` dist-tag until a stable 1.0. The per-phase sections below double as the detailed feature reference; start with [Features](#features) and [Quickstart](#quickstart).
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Field model** -- `defineFormField` defines a field type once and yields its admin authoring, a typed isomorphic `validate`, a localized `format`, and a value kind. Core set: text, textarea, email, number, select, checkbox, date, file, consent, calculation. Every seam is `false | true | object` overridable.
|
|
14
|
+
- **Validation subsystem** -- declarative per-field rules (`defineValidationRule`), custom messages + severity, cross-field + async server-only rules, a Standard Schema escape hatch, one server-authoritative engine.
|
|
15
|
+
- **Conditional logic** -- serializable `visibleWhen`/`validateWhen` with a native Payload-style builder, evaluated by one isomorphic engine (client preview + server enforcement).
|
|
16
|
+
- **Headless renderer** -- `@10x-media/form-builder/react`: `<Form>` with progressive validation, conditional visibility, lifecycle events, accessible primitives + built-in renderers, an optional container-query layout grid; a shadcn registry block and bring-your-own renderers.
|
|
17
|
+
- **Multi-step flow** -- a serializable flow state machine with conditional branching/skipping.
|
|
18
|
+
- **Presentations** -- page, modal, drawer, inline (+ custom), with composable accessible overlay primitives.
|
|
19
|
+
- **Recall + prefill** -- pipe earlier answers into later labels and the thank-you screen; URL/query prefill; hidden context fields.
|
|
20
|
+
- **Calculations + scoring** -- a safe (no-eval) expression engine for pricing, quotes, and quizzes.
|
|
21
|
+
- **Post-submit pipeline** -- email, confirmation, signed webhook, and custom actions (queued via Payload jobs / `@10x-media/jobs`, with a bounded-inline fallback) + a typed lifecycle event taxonomy through a pluggable sink.
|
|
22
|
+
- **Consent** -- a compliant consent field with three sources + a published-version capture utility, proof by reference.
|
|
23
|
+
- **Polls + aggregation** -- a submission-aggregation utility, `<FormResults>` (headless + shadcn), and a `<Poll>` pattern.
|
|
24
|
+
- **File uploads** -- a file field backed by a configurable upload collection, server-enforced MIME/size/required, self-describing references.
|
|
25
|
+
- **Spam basics** -- honeypot + rate-limiting on by default, a captcha adapter seam, upload-ownership scoping, privacy-first capture metadata.
|
|
26
|
+
- **Accessibility** -- accessible defaults verified by automated axe checks (jsdom + real-browser e2e); a documented a11y contract.
|
|
27
|
+
- **i18n** -- typed keys, flat `en`, host-overridable, never depending on `@payloadcms/translations`.
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
Add the plugin to your Payload config:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { formBuilder } from '@10x-media/form-builder'
|
|
35
|
+
|
|
36
|
+
export default buildConfig({
|
|
37
|
+
// ...
|
|
38
|
+
plugins: [formBuilder()],
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Author a form in the admin (the `forms` collection), fetch it, and render it with the headless renderer:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { Form } from '@10x-media/form-builder/react'
|
|
46
|
+
import '@10x-media/form-builder/styles.css'
|
|
47
|
+
|
|
48
|
+
export function ContactForm({ form }: { form: FormDocument }) {
|
|
49
|
+
return <Form form={form} />
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`form` is a `forms` document loaded via Payload's Local or REST API. A complete working example (a multi-step form and a poll) lives in this package's `dev/app/(frontend)/`. For styled components, install the shadcn registry block (see [shadcn registry](#styled-components-shadcn-registry)); to bring your own markup, supply renderers via `defineFieldRenderer` (see [BYO styling](#bring-your-own-styling-byo)).
|
|
54
|
+
|
|
55
|
+
## Fields and submissions
|
|
56
|
+
|
|
57
|
+
- **`defineFormField`** defines a field type once and yields four facets from a single object: a Payload `Field[]` for the add-field drawer, a typed isomorphic `validate`, a localized `format`, and a `value` kind that drives the typed value threaded into `validate`/`format`.
|
|
58
|
+
- **Core field set**: `text`, `textarea`, `email`, `number`, `select`, and `checkbox`, each authored through the same primitive so custom field types are never second-class.
|
|
59
|
+
- **Field-type registry** via the `fields` option: `false` removes a built-in, `true` keeps it, an object adds a new type or replaces one.
|
|
60
|
+
- **Authoring via native blocks**: form fields are composed in a Payload blocks array, one block per registered field type.
|
|
61
|
+
- **Typed, self-describing, localized submissions**: each submission stores typed values plus a localized descriptor snapshot taken at submit time, validated server-side. The client is never trusted.
|
|
62
|
+
- **Formatted admin answers view**: submissions render through a read-only answers view that formats each value with its field type's `format`.
|
|
63
|
+
|
|
64
|
+
## Validation
|
|
65
|
+
|
|
66
|
+
- **Per-field validation rules in the admin**: each field carries a constraint list authored as native blocks. Built-in rules cover `minLength`, `maxLength`, `min`, `max`, `pattern`, `email`, `url`, `oneOf`, `matchesField`, and `notAlreadySubmitted`.
|
|
67
|
+
- **Custom localized messages and severity**: every rule instance can override its message with `{var}` interpolation and run as an `error` (blocks submission) or a `warning` (advisory).
|
|
68
|
+
- **One server-authoritative engine**: rules run through a single engine, including cross-field rules (`matchesField`) and async server-only rules (`notAlreadySubmitted`). The server is the source of truth; the client is never trusted.
|
|
69
|
+
- **Custom rule types** via `defineValidationRule`: define a rule type once and it yields its admin params and a typed `validate` for the engine, exactly like the built-ins. Override the registry through the `rules` option: `false` removes a built-in, `true` keeps it, an object adds a new rule or replaces one.
|
|
70
|
+
- **Standard Schema escape hatch**: each field type can validate against any [Standard Schema](https://standardschema.dev) validator (zod, valibot, and others), bypassing the rule list when a schema is the better fit.
|
|
71
|
+
|
|
72
|
+
## Conditional logic
|
|
73
|
+
|
|
74
|
+
- **`visibleWhen` and `validateWhen` per field**: every field can declare two conditions in Payload's `Where` shape over its sibling answers, for example `{ or: [{ and: [{ country: { equals: 'US' } }] }] }`. The same query operators you already use elsewhere in Payload drive form logic.
|
|
75
|
+
- **Hidden fields are skipped entirely**: when `visibleWhen` evaluates false, the field is not validated, its value is not stored, and any value the client sent for it is ignored. A hidden field cannot leak data into a submission.
|
|
76
|
+
- **`validateWhen` gates validation, not storage**: when `validateWhen` evaluates false, the field's validation rules are skipped but its value is still stored. Use it for answers that are only required under certain conditions.
|
|
77
|
+
- **Server-authoritative, pure, and isomorphic**: conditions are enforced server-side by `evaluateCondition`, a pure engine that mirrors Payload's query-operator semantics (coerce then compare) with no `req` or database access. It is exported so the renderer and your own code can reuse the exact same logic client-side.
|
|
78
|
+
- **Native condition builder UI**: conditions are authored with a Payload-style condition builder on each field (field, operator, value, the same look as Payload's list filters), stored as a canonical `Where`, normalized and validated server-side. The serializable format means the renderer reuses the exact same `evaluateCondition` client-side.
|
|
79
|
+
|
|
80
|
+
## Headless renderer
|
|
81
|
+
|
|
82
|
+
`@10x-media/form-builder/react` exports the headless renderer layer. It has no opinion on styling; bring your own CSS or opt into the included container-query grid.
|
|
83
|
+
|
|
84
|
+
### Subpath
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { defineFieldRenderer, resolveRenderers, defaultRenderers } from '@10x-media/form-builder/react'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Renderer contract
|
|
91
|
+
|
|
92
|
+
A field renderer is a React component that maps `FieldRendererProps` to output. Use `defineFieldRenderer` to pin the type and get prop inference:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { defineFieldRenderer } from '@10x-media/form-builder/react'
|
|
96
|
+
|
|
97
|
+
const myTextRenderer = defineFieldRenderer<string>(({ id, name, value, onChange, onBlur, errors, required, disabled, t }) => (
|
|
98
|
+
<input
|
|
99
|
+
id={id}
|
|
100
|
+
name={name}
|
|
101
|
+
value={value ?? ''}
|
|
102
|
+
onChange={(e) => onChange(e.target.value)}
|
|
103
|
+
onBlur={onBlur}
|
|
104
|
+
required={required}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
aria-invalid={errors.length > 0 || undefined}
|
|
107
|
+
/>
|
|
108
|
+
))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`FieldRendererProps<TValue>` carries the field instance, stable `id`, machine `name`, `value`, `onChange`/`onBlur`, `errors`/`warnings`, `required`, `disabled`, `locale`, and a `t` translator.
|
|
112
|
+
|
|
113
|
+
### Renderer registry
|
|
114
|
+
|
|
115
|
+
`resolveRenderers` merges a base renderer map with a consumer override config. The same `false/true/object` convention used throughout the plugin applies here: `false` removes a type, `true` keeps the default, a renderer adds or replaces one.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { defaultRenderers, resolveRenderers } from '@10x-media/form-builder/react'
|
|
119
|
+
|
|
120
|
+
const registry = resolveRenderers(defaultRenderers, {
|
|
121
|
+
text: myTextRenderer, // replace the built-in text renderer
|
|
122
|
+
number: false, // remove the number renderer entirely
|
|
123
|
+
rating: ratingRenderer, // add a custom type
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Unstyled accessible primitives
|
|
128
|
+
|
|
129
|
+
The six control primitives are exported for use inside custom renderers. Each is an unstyled, accessible HTML control that wires `aria-invalid` and `aria-describedby` but leaves visual styling to you:
|
|
130
|
+
|
|
131
|
+
| Primitive | Element |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `Input` | text, email, or number `<input>` |
|
|
134
|
+
| `Textarea` | `<textarea>` |
|
|
135
|
+
| `Select` | `<select>` with typed `SelectOption[]` |
|
|
136
|
+
| `Checkbox` | `<input type="checkbox">` |
|
|
137
|
+
| `FieldShell` | Label + control slot + description + error/warning messages |
|
|
138
|
+
|
|
139
|
+
Pair them with `FieldShell`, which renders the `<label>`, the control slot, and the description/error/warning region with the matching `aria-describedby` wiring.
|
|
140
|
+
|
|
141
|
+
### Built-in renderers
|
|
142
|
+
|
|
143
|
+
`defaultRenderers` is a `Record<string, FieldRenderer>` keyed by field-type slug, covering `text`, `textarea`, `email`, `number`, `select`, and `checkbox`. Each built-in renderer uses `FieldShell` and the matching primitive.
|
|
144
|
+
|
|
145
|
+
### Optional layout grid
|
|
146
|
+
|
|
147
|
+
To enable the container-query layout grid, import the stylesheet once at the app boundary:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import '@10x-media/form-builder/styles.css'
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Then use `FormLayout` and `widthProps` to place fields in a responsive grid:
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
import { FormLayout, widthProps } from '@10x-media/form-builder/react'
|
|
157
|
+
|
|
158
|
+
<FormLayout>
|
|
159
|
+
<div {...widthProps('half')}>{/* first name */}</div>
|
|
160
|
+
<div {...widthProps('half')}>{/* last name */}</div>
|
|
161
|
+
<div {...widthProps('full')}>{/* message */}</div>
|
|
162
|
+
</FormLayout>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`FieldWidth` values: `full`, `half`, `third`, `twoThirds`. Omit the import for your own layout; `FormLayout` still renders a plain container wrapper with no grid class when `enabled={false}`.
|
|
166
|
+
|
|
167
|
+
## The Form controller
|
|
168
|
+
|
|
169
|
+
The orchestrating `<Form>` component and the hooks that power fully custom layouts.
|
|
170
|
+
|
|
171
|
+
### Basic usage
|
|
172
|
+
|
|
173
|
+
```tsx
|
|
174
|
+
import { Form } from '@10x-media/form-builder/react'
|
|
175
|
+
|
|
176
|
+
export function MyForm({ form }) {
|
|
177
|
+
return (
|
|
178
|
+
<Form
|
|
179
|
+
form={form}
|
|
180
|
+
onSuccess={(submissionId) => console.log('submitted', submissionId)}
|
|
181
|
+
onError={(message) => console.error(message)}
|
|
182
|
+
/>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`form` is a `FormDocument`: `{ id: string | number; fields: FormFieldInstance[] }`. Pass the document fetched from the Payload API directly.
|
|
188
|
+
|
|
189
|
+
### Progressive validation
|
|
190
|
+
|
|
191
|
+
Validation runs on blur, re-validates on change once a field has been touched, then validates all visible fields on submit. Hidden fields (those whose `visibleWhen` condition evaluates false) are excluded from validation and from the submitted values. Server-returned field errors are mapped back to the correct field after submission.
|
|
192
|
+
|
|
193
|
+
### Submission transport
|
|
194
|
+
|
|
195
|
+
By default `<Form>` POSTs to `{apiRoute}/form-submissions` (default `/api`). Override the route via `apiRoute`:
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
<Form form={form} apiRoute="/api/v2" />
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Provide a fully custom transport via `onSubmit`. The handler receives `{ formId, values }` and must return a `SubmitFormResult`:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
<Form
|
|
205
|
+
form={form}
|
|
206
|
+
onSubmit={async ({ formId, values }) => {
|
|
207
|
+
const res = await myClient.submitForm(formId, values)
|
|
208
|
+
return res.ok ? { ok: true } : { ok: false, message: res.error }
|
|
209
|
+
}}
|
|
210
|
+
/>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Lifecycle events
|
|
214
|
+
|
|
215
|
+
Pass an `events` sink to observe form lifecycle:
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
import type { FormEventSink } from '@10x-media/form-builder/react'
|
|
219
|
+
|
|
220
|
+
const sink: FormEventSink = {
|
|
221
|
+
emit: (event) => analytics.track(event.type, event),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
<Form form={form} events={sink} />
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Events emitted: `form.viewed`, `form.started`, `field.errored`, `submission.created`, `form.abandoned`.
|
|
228
|
+
|
|
229
|
+
### FormProps reference
|
|
230
|
+
|
|
231
|
+
| Prop | Type | Default | Description |
|
|
232
|
+
|---|---|---|---|
|
|
233
|
+
| `form` | `FormDocument` | required | The form document (id + fields). |
|
|
234
|
+
| `fieldTypes` | `AnyFormFieldDefinition[]` | `undefined` | Extra field-type definitions to resolve renderers for custom types. |
|
|
235
|
+
| `rules` | `AnyValidationRuleDefinition[]` | `undefined` | Extra validation rule definitions. |
|
|
236
|
+
| `renderers` | `RenderersConfig` | `undefined` | Renderer overrides (same `false/true/object` convention). |
|
|
237
|
+
| `apiRoute` | `string` | `'/api'` | Payload API route prefix for the built-in transport. |
|
|
238
|
+
| `onSubmit` | `SubmitHandler` | `undefined` | Custom transport override. |
|
|
239
|
+
| `onSuccess` | `(submissionId?: string) => void` | `undefined` | Called after a successful submission. |
|
|
240
|
+
| `onError` | `(message: string) => void` | `undefined` | Called when submission fails. |
|
|
241
|
+
| `events` | `FormEventSink` | `undefined` | Lifecycle event sink. |
|
|
242
|
+
| `t` | `RendererTranslate` | `(key) => key` | Translator for renderer labels. |
|
|
243
|
+
| `locale` | `string` | `'en'` | Locale passed to renderers and validation. |
|
|
244
|
+
| `layout` | `boolean` | `true` | Pass `false` to disable the container-query grid wrapper. |
|
|
245
|
+
| `submitLabel` | `string` | `'Submit'` | Label for the submit button. |
|
|
246
|
+
| `successMessage` | `string` | `'Thank you.'` | Message shown after a successful submission. |
|
|
247
|
+
|
|
248
|
+
### Custom layouts with `useFormState` and `useField`
|
|
249
|
+
|
|
250
|
+
For fully custom layouts, pass your own markup as `children` of `<Form>` and bind each field with the context hooks. `useFormState` returns the whole `FormState`; `useField(name)` binds one field:
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
import { Form, useField, useFormState } from '@10x-media/form-builder/react'
|
|
254
|
+
|
|
255
|
+
function NameField() {
|
|
256
|
+
const { value, errors, setValue, onBlur } = useField<string>('name')
|
|
257
|
+
return (
|
|
258
|
+
<label>
|
|
259
|
+
Name
|
|
260
|
+
<input
|
|
261
|
+
value={value ?? ''}
|
|
262
|
+
onChange={(e) => setValue(e.target.value)}
|
|
263
|
+
onBlur={onBlur}
|
|
264
|
+
aria-invalid={errors.length > 0 || undefined}
|
|
265
|
+
/>
|
|
266
|
+
{errors.map((e) => <span key={e}>{e}</span>)}
|
|
267
|
+
</label>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Pass fields as children; <Form> provides the context and owns submission.
|
|
272
|
+
function ContactForm({ form }) {
|
|
273
|
+
return (
|
|
274
|
+
<Form form={form}>
|
|
275
|
+
<NameField />
|
|
276
|
+
<button type="submit">Send</button>
|
|
277
|
+
</Form>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
When you pass `children`, `<Form>` renders them inside its context (instead of the auto-rendered field loop and default submit button), so you control the entire layout.
|
|
283
|
+
|
|
284
|
+
`UseFieldResult<TValue>` carries: `value`, `errors`, `warnings`, `touched`, `setValue`, and `onBlur`.
|
|
285
|
+
|
|
286
|
+
`FormState` carries: `values`, `errors`, `warnings`, `touched`, `submitting`, `submitted`, `submitAttempted`, and `submitError`.
|
|
287
|
+
|
|
288
|
+
## Styled components: shadcn registry
|
|
289
|
+
|
|
290
|
+
A [shadcn registry](https://ui.shadcn.com/docs/registry) block installs styled field renderers and a `<FormBuilderForm>` preconfigured with them into your own codebase. You own the copied files and can restyle them however you like.
|
|
291
|
+
|
|
292
|
+
### Install
|
|
293
|
+
|
|
294
|
+
The registry JSON is built to `registry/r/form.json` in the package source; hosting it on the docs site is a follow-up. Until then, point the shadcn CLI at the raw path:
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
npx shadcn@latest add <registry-url>/r/form.json
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Once the registry is hosted, register it as a namespace in your `components.json` and install by short name:
|
|
301
|
+
|
|
302
|
+
```json
|
|
303
|
+
{
|
|
304
|
+
"registries": {
|
|
305
|
+
"@formbuilder": "https://<registry-host>/r/{name}.json"
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
npx shadcn@latest add @formbuilder/form
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### What gets installed
|
|
315
|
+
|
|
316
|
+
| Item | Destination |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `@10x-media/form-builder` | added to `dependencies` |
|
|
319
|
+
| shadcn `input`, `textarea`, `label` primitives | `components/ui/` |
|
|
320
|
+
| `<FormBuilderForm>` | `components/form-builder/form-builder-form.tsx` |
|
|
321
|
+
| styled renderers map (`shadcnRenderers`) | `components/form-builder/renderers.tsx` |
|
|
322
|
+
| six styled field renderers | `components/form-builder/fields/*.tsx` |
|
|
323
|
+
|
|
324
|
+
### Usage
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
import { FormBuilderForm } from '@/components/form-builder/form-builder-form'
|
|
328
|
+
|
|
329
|
+
export function MyForm({ form }) {
|
|
330
|
+
return (
|
|
331
|
+
<FormBuilderForm
|
|
332
|
+
form={form}
|
|
333
|
+
onSuccess={(id) => console.log('submitted', id)}
|
|
334
|
+
/>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`<FormBuilderForm>` is `<Form>` preconfigured with the shadcn-styled renderers. Any `renderers` prop you pass merges on top, so you can override individual fields or add custom types without touching the rest.
|
|
340
|
+
|
|
341
|
+
The copied files are yours: edit typography, spacing, colors, or swap in a different component library's primitives. The shipped styling is a shadcn-convention baseline, not an API.
|
|
342
|
+
|
|
343
|
+
## Bring your own styling (BYO)
|
|
344
|
+
|
|
345
|
+
### When to BYO vs. shadcn
|
|
346
|
+
|
|
347
|
+
Use the shadcn registry when you want a working styled form in one command and your project already uses shadcn. BYO is for: projects that don't use shadcn; a design system that already has a component library; situations where you want to keep the control primitives and ship only CSS; or when you want full markup control from scratch.
|
|
348
|
+
|
|
349
|
+
Three paths from lightest to heaviest:
|
|
350
|
+
|
|
351
|
+
### Path 1: restyle with CSS on top of the unstyled primitives
|
|
352
|
+
|
|
353
|
+
The built-in renderers (via `defaultRenderers`) render through the unstyled primitives. Every element carries a stable class hook. Write CSS that targets those classes and nothing else needs to change.
|
|
354
|
+
|
|
355
|
+
| Class | Element |
|
|
356
|
+
|---|---|
|
|
357
|
+
| `fb-field` | wrapper `<div>` around the entire field |
|
|
358
|
+
| `fb-field__label` | `<label>` |
|
|
359
|
+
| `fb-field__required` | required asterisk `<span>` (aria-hidden) |
|
|
360
|
+
| `fb-field__messages` | description + errors + warnings region |
|
|
361
|
+
| `fb-field__description` | `<p>` description line |
|
|
362
|
+
| `fb-field__errors` | error group `<div>` (role=alert, aria-atomic) |
|
|
363
|
+
| `fb-field__error` | individual error `<p>` |
|
|
364
|
+
| `fb-field__warning` | individual warning `<p>` |
|
|
365
|
+
| `fb-input` | `<input type="text|email|number">` |
|
|
366
|
+
| `fb-textarea` | `<textarea>` |
|
|
367
|
+
| `fb-select` | `<select>` |
|
|
368
|
+
| `fb-checkbox` | `<input type="checkbox">` |
|
|
369
|
+
|
|
370
|
+
The wrapper `<div>` also receives a `data-invalid` attribute (present with empty string value) when the field has errors. Target it for error-state styling:
|
|
371
|
+
|
|
372
|
+
```css
|
|
373
|
+
.fb-field[data-invalid] .fb-input {
|
|
374
|
+
border-color: red;
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
For the container-query layout grid, import the stylesheet and use `FormLayout` + `widthProps` as described in the headless renderer section. The grid uses `fb-form--grid` on the container and `data-width` on each field slot (`full`, `half`, `third`, `twoThirds`).
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
import '@10x-media/form-builder/styles.css'
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Path 2: custom renderers with `defineFieldRenderer`
|
|
385
|
+
|
|
386
|
+
Author a renderer per field type and register it through `resolveRenderers`. See the [renderer contract section](#renderer-contract) for the full `FieldRendererProps` shape.
|
|
387
|
+
|
|
388
|
+
Use `resolveRenderers` to merge your overrides with the defaults:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { defaultRenderers, resolveRenderers } from '@10x-media/form-builder/react'
|
|
392
|
+
|
|
393
|
+
const registry = resolveRenderers(defaultRenderers, {
|
|
394
|
+
text: myText, // replace the built-in text renderer
|
|
395
|
+
number: false, // remove number entirely
|
|
396
|
+
})
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Pass `false` to remove a type, `true` to keep the default, or a renderer to replace it. The same convention applies throughout the plugin.
|
|
400
|
+
|
|
401
|
+
A complete custom text renderer with full a11y wiring:
|
|
402
|
+
|
|
403
|
+
```tsx
|
|
404
|
+
import { defineFieldRenderer } from '@10x-media/form-builder/react'
|
|
405
|
+
import { useId } from 'react'
|
|
406
|
+
|
|
407
|
+
const myText = defineFieldRenderer<string>(
|
|
408
|
+
({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
409
|
+
const id = useId()
|
|
410
|
+
const describedById = `${id}-desc`
|
|
411
|
+
const label = typeof field.label === 'string' ? field.label : undefined
|
|
412
|
+
const invalid = errors.length > 0
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div>
|
|
416
|
+
{label ? (
|
|
417
|
+
<label htmlFor={id}>
|
|
418
|
+
{label}
|
|
419
|
+
{required ? <span aria-hidden>{' *'}</span> : null}
|
|
420
|
+
</label>
|
|
421
|
+
) : null}
|
|
422
|
+
<input
|
|
423
|
+
id={id}
|
|
424
|
+
name={name}
|
|
425
|
+
type="text"
|
|
426
|
+
value={value ?? ''}
|
|
427
|
+
required={required}
|
|
428
|
+
disabled={disabled}
|
|
429
|
+
aria-invalid={invalid || undefined}
|
|
430
|
+
aria-describedby={describedById}
|
|
431
|
+
onChange={(e) => onChange(e.target.value)}
|
|
432
|
+
onBlur={onBlur}
|
|
433
|
+
/>
|
|
434
|
+
<div id={describedById}>
|
|
435
|
+
{invalid ? (
|
|
436
|
+
<div role="alert" aria-atomic>
|
|
437
|
+
{errors.map((msg) => <p key={msg}>{msg}</p>)}
|
|
438
|
+
</div>
|
|
439
|
+
) : null}
|
|
440
|
+
{warnings?.map((msg) => <p key={msg}>{msg}</p>)}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
Then pass the resolved registry to `<Form>`:
|
|
449
|
+
|
|
450
|
+
```tsx
|
|
451
|
+
import { Form, defaultRenderers, resolveRenderers } from '@10x-media/form-builder/react'
|
|
452
|
+
|
|
453
|
+
const renderers = resolveRenderers(defaultRenderers, { text: myText })
|
|
454
|
+
|
|
455
|
+
<Form form={form} renderers={renderers} />
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Path 3: drop to `useField` / `useFormState`
|
|
459
|
+
|
|
460
|
+
For total markup control, pass children to `<Form>` and bind fields with the context hooks. See the [custom layouts section](#custom-layouts-with-useformstate-and-usefield).
|
|
461
|
+
|
|
462
|
+
### A11y checklist for custom renderers
|
|
463
|
+
|
|
464
|
+
Any renderer you write must satisfy these to match `FieldShell`'s baseline:
|
|
465
|
+
|
|
466
|
+
- `<label htmlFor={id}>` where `id` matches the control's `id` prop.
|
|
467
|
+
- `aria-invalid` set to `true` (or left absent) -- never set it to `false`; omit it when there are no errors.
|
|
468
|
+
- `aria-describedby={describedById}` on the control, pointing at a region that contains errors and warnings.
|
|
469
|
+
- The error region must have `role="alert"` and `aria-atomic` so screen readers announce it on change.
|
|
470
|
+
- The required asterisk (or whatever indicator you use) must be `aria-hidden`; do not rely on it as the sole signal.
|
|
471
|
+
- `required` on the native control so browser-native validation is consistent.
|
|
472
|
+
|
|
473
|
+
## Multi-step forms
|
|
474
|
+
|
|
475
|
+
A form document can carry an optional `flow` property that layers a serializable step graph over its flat field list. When `flow` is absent or has fewer than two steps, `<Form>` behaves as a standard single-step form; no migration or schema change is needed to adopt the feature incrementally.
|
|
476
|
+
|
|
477
|
+
### Flow data model
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
import type { FormFlow } from '@10x-media/form-builder/types'
|
|
481
|
+
|
|
482
|
+
const flow: FormFlow = {
|
|
483
|
+
steps: [
|
|
484
|
+
{
|
|
485
|
+
id: 'contact',
|
|
486
|
+
title: 'Contact details',
|
|
487
|
+
fields: ['name', 'email'],
|
|
488
|
+
next: 'message',
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: 'message',
|
|
492
|
+
title: 'Your message',
|
|
493
|
+
fields: ['subject', 'body'],
|
|
494
|
+
transitions: [
|
|
495
|
+
{ when: { subject: { equals: 'urgent' } }, to: 'urgent-confirm' },
|
|
496
|
+
],
|
|
497
|
+
next: 'confirm',
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
id: 'urgent-confirm',
|
|
501
|
+
title: 'Confirm urgent request',
|
|
502
|
+
fields: ['confirmUrgent'],
|
|
503
|
+
},
|
|
504
|
+
{ id: 'confirm', title: 'Review', fields: ['consent'] },
|
|
505
|
+
],
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Each `FlowStep` groups a subset of the form's fields by machine name. The `transitions` array is evaluated in order: the first entry whose `when` condition matches the current answers (via the same `evaluateCondition` engine used by `visibleWhen`/`validateWhen`) wins. If no transition matches, `next` is the default next step. A step with neither a matching transition nor `next` is terminal.
|
|
510
|
+
|
|
511
|
+
The `flow` is authored as data for now; a visual flow builder is a later release. Pass it alongside `fields` on the `FormDocument`:
|
|
512
|
+
|
|
513
|
+
```ts
|
|
514
|
+
import type { FormDocument } from '@10x-media/form-builder/react'
|
|
515
|
+
|
|
516
|
+
const form: FormDocument = { id: 'contact-form', fields: [...], flow }
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Renderer behavior
|
|
520
|
+
|
|
521
|
+
`<Form>` auto-drives the steps:
|
|
522
|
+
|
|
523
|
+
- Renders only the current step's fields (hides the rest).
|
|
524
|
+
- Back and Next buttons appear between the first and last steps.
|
|
525
|
+
- Advancing validates the current step's visible fields; errors block progress.
|
|
526
|
+
- Reaching a terminal step shows the Submit button instead of Next.
|
|
527
|
+
- Submission sends all accumulated answers as a single form submission.
|
|
528
|
+
|
|
529
|
+
No additional props are required; `<Form>` reads the `flow` from the form document.
|
|
530
|
+
|
|
531
|
+
### `useFormStep` for custom UIs and progress indicators
|
|
532
|
+
|
|
533
|
+
```tsx
|
|
534
|
+
import { useFormStep } from '@10x-media/form-builder/react'
|
|
535
|
+
|
|
536
|
+
function StepProgress() {
|
|
537
|
+
const { stepIndex, stepCount, isFirst, isTerminal, currentStepId } = useFormStep()
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<p>
|
|
541
|
+
Step {stepIndex + 1} of {stepCount}
|
|
542
|
+
{isTerminal ? ' (final)' : null}
|
|
543
|
+
</p>
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
`stepIndex` and `stepCount` reflect the declared step order, so this reads exactly for linear flows. For branching flows, where some steps are skipped, derive progress from `currentStepId` (or track the visited steps yourself) rather than the raw index.
|
|
549
|
+
|
|
550
|
+
`useFormStep()` returns a `FormStepInfo`:
|
|
551
|
+
|
|
552
|
+
| Field | Type | Description |
|
|
553
|
+
|---|---|---|
|
|
554
|
+
| `flow` | `FormFlow \| undefined` | The full flow, or `undefined` for a single-step form. |
|
|
555
|
+
| `currentStepId` | `string \| undefined` | Machine id of the active step. |
|
|
556
|
+
| `stepIndex` | `number` | Zero-based index within the ordered step list. `0` for single-step. |
|
|
557
|
+
| `stepCount` | `number` | Total number of steps. `1` for single-step. |
|
|
558
|
+
| `isFirst` | `boolean` | `true` when on the first step. |
|
|
559
|
+
| `isTerminal` | `boolean` | `true` when no transition or `next` would advance further. |
|
|
560
|
+
| `goNext` | `() => void` | Advance to the next step (validates current step first). |
|
|
561
|
+
| `goBack` | `() => void` | Return to the previous step. |
|
|
562
|
+
|
|
563
|
+
### Isomorphic flow engine
|
|
564
|
+
|
|
565
|
+
The flow engine is exported from `@10x-media/form-builder/react` so you can drive custom step UIs without reimplementing the graph logic:
|
|
566
|
+
|
|
567
|
+
```ts
|
|
568
|
+
import {
|
|
569
|
+
firstStepId,
|
|
570
|
+
resolveNextStepId,
|
|
571
|
+
isTerminalStepId,
|
|
572
|
+
stepFieldNames,
|
|
573
|
+
} from '@10x-media/form-builder/react'
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
| Export | Signature | Description |
|
|
577
|
+
|---|---|---|
|
|
578
|
+
| `firstStepId` | `(flow) => string \| undefined` | Id of the first step. |
|
|
579
|
+
| `resolveNextStepId` | `(flow, currentId, answers) => string \| undefined` | Next step id given current answers. |
|
|
580
|
+
| `isTerminalStepId` | `(flow, currentId, answers) => boolean` | Whether the current step is terminal. |
|
|
581
|
+
| `stepFieldNames` | `(flow, id) => string[]` | Field machine names for a given step id. |
|
|
582
|
+
|
|
583
|
+
All four are pure and isomorphic (no React, no DOM).
|
|
584
|
+
|
|
585
|
+
## Presentations
|
|
586
|
+
|
|
587
|
+
A presentation is a named configuration bundle that controls how a form is surfaced to the visitor: where it appears in the page, how the overlay behaves, and whether it dismisses on success. Four presentations ship by default.
|
|
588
|
+
|
|
589
|
+
| Name | Surface | Behavior |
|
|
590
|
+
|---|---|---|
|
|
591
|
+
| `page` | Full page (default) | Renders inline in the page flow; no overlay. |
|
|
592
|
+
| `inline` | Embedded | Same as `page` but semantically scoped to an embedded slot. |
|
|
593
|
+
| `modal` | Overlay | Centered dialog; dismisses on success. |
|
|
594
|
+
| `drawer` | Overlay | Side-panel; dismisses on success. |
|
|
595
|
+
|
|
596
|
+
### Editor-chosen default
|
|
597
|
+
|
|
598
|
+
The Forms collection exposes a `defaultPresentation` select field in the admin UI. Editors choose a presentation when authoring the form; the renderer uses it automatically. When no presentation is chosen, `page` is the fallback.
|
|
599
|
+
|
|
600
|
+
### Render-time override
|
|
601
|
+
|
|
602
|
+
Pass the `presentation` prop to `<Form>` to override whatever the document carries. The value is either a registered name string or an inline `FormPresentation` object:
|
|
603
|
+
|
|
604
|
+
```tsx
|
|
605
|
+
import { Form } from '@10x-media/form-builder/react'
|
|
606
|
+
|
|
607
|
+
<Form form={form} presentation="modal" onClose={() => setOpen(false)} />
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
The `onClose` prop is forwarded to the presentation's `Wrapper` component (used by `modal` and `drawer`) so the host controls when the overlay closes.
|
|
611
|
+
|
|
612
|
+
### Presentation props on `<Form>`
|
|
613
|
+
|
|
614
|
+
| Prop | Type | Default | Description |
|
|
615
|
+
|---|---|---|---|
|
|
616
|
+
| `presentation` | `string \| FormPresentation` | `undefined` | Override the document's `defaultPresentation`. |
|
|
617
|
+
| `presentations` | `PresentationsConfig` | `undefined` | Registry override (`false/true/object` per name). |
|
|
618
|
+
| `onClose` | `() => void` | `undefined` | Forwarded to overlay wrappers for close-trigger handling. |
|
|
619
|
+
| `title` | `string` | `undefined` | Accessible name passed to overlay wrappers (e.g. `aria-label` on the dialog). |
|
|
620
|
+
|
|
621
|
+
### Custom presentations
|
|
622
|
+
|
|
623
|
+
Register a custom presentation through the `presentations` prop on `<Form>` (for a one-off) or via the plugin `presentations` option (to add it to the admin select and make it available everywhere). The same `false/true/object` convention used throughout the plugin applies here.
|
|
624
|
+
|
|
625
|
+
Via the plugin option:
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
import { formBuilder } from '@10x-media/form-builder'
|
|
629
|
+
|
|
630
|
+
formBuilder({
|
|
631
|
+
presentations: {
|
|
632
|
+
popover: false, // remove a built-in
|
|
633
|
+
modal: true, // keep the default modal
|
|
634
|
+
fullscreen: { // add a new descriptor
|
|
635
|
+
name: 'fullscreen',
|
|
636
|
+
label: 'formBuilder:presentationFullscreen',
|
|
637
|
+
surface: 'overlay',
|
|
638
|
+
density: 'comfortable',
|
|
639
|
+
dismissOnSuccess: true,
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
})
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
To provide a React `Wrapper` component for your custom presentation (required for overlays), pass a `FormPresentation` object directly to `<Form>`:
|
|
646
|
+
|
|
647
|
+
```tsx
|
|
648
|
+
import type { FormPresentation } from '@10x-media/form-builder/react'
|
|
649
|
+
import { DialogSurface } from '@10x-media/form-builder/react'
|
|
650
|
+
|
|
651
|
+
const fullscreen: FormPresentation = {
|
|
652
|
+
name: 'fullscreen',
|
|
653
|
+
label: 'formBuilder:presentationFullscreen',
|
|
654
|
+
surface: 'overlay',
|
|
655
|
+
density: 'comfortable',
|
|
656
|
+
dismissOnSuccess: true,
|
|
657
|
+
Wrapper: ({ open, onClose, title, children }) => (
|
|
658
|
+
<DialogSurface open={open} onClose={onClose} label={title} surface="fullscreen">
|
|
659
|
+
{children}
|
|
660
|
+
</DialogSurface>
|
|
661
|
+
),
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
<Form form={form} presentations={{ fullscreen }} presentation="fullscreen" onClose={close} />
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
### Composable overlay primitives
|
|
668
|
+
|
|
669
|
+
The built-in `modal` and `drawer` are a thin composition of individually-exported primitives. A headless consumer can reach for exactly the pieces they need, or none at all.
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
import {
|
|
673
|
+
DialogSurface, // Backdrop + role=dialog/aria-modal + focus-trap + scroll-lock + dismiss
|
|
674
|
+
Backdrop, // full-viewport backdrop with data-fb-backdrop hook
|
|
675
|
+
useFocusTrap, // keeps Tab/Shift-Tab inside a container while active
|
|
676
|
+
useScrollLock, // locks body scroll while an overlay is open
|
|
677
|
+
useDismiss, // wires Escape and outside-click to an onDismiss callback
|
|
678
|
+
} from '@10x-media/form-builder/react'
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Options:
|
|
682
|
+
|
|
683
|
+
- Use `inline` (or no presentation) for zero overlay DOM, then apply your own positioning.
|
|
684
|
+
- Compose only `useFocusTrap` + `useScrollLock` + `useDismiss` inside your own wrapper component.
|
|
685
|
+
- Use `Backdrop` standalone for a custom scrim behind your own surface markup.
|
|
686
|
+
- Use `DialogSurface` directly to get the full accessible dialog behavior and swap CSS only.
|
|
687
|
+
|
|
688
|
+
The built-in modal is `DialogSurface` plus a close button; the built-in drawer adds a `surface="drawer"` data hook for CSS positioning. Neither imposes a shadow DOM boundary or an opinionated style layer.
|
|
689
|
+
|
|
690
|
+
### Accessibility baseline
|
|
691
|
+
|
|
692
|
+
The built-in overlay (`DialogSurface`) ships a dependency-free, spec-compliant baseline:
|
|
693
|
+
|
|
694
|
+
- Full-viewport `Backdrop` (`aria-hidden`; click to dismiss).
|
|
695
|
+
- `role="dialog"` / `aria-modal` on the surface panel.
|
|
696
|
+
- Focus-trap: Tab and Shift-Tab cycle inside the dialog while it is open.
|
|
697
|
+
- Initial focus on the panel (`tabIndex=-1`) on open; focus restored to the trigger on close.
|
|
698
|
+
- Scroll-lock: `overflow: hidden` on `<body>` while the overlay is open.
|
|
699
|
+
- Escape key dismiss (configurable via `closeOnEscape`, default `true`).
|
|
700
|
+
- Outside-click dismiss (configurable via `closeOnOutsideClick`, default `true`).
|
|
701
|
+
- Accessible close button (`aria-label="Close"`, configurable via `closeLabel`).
|
|
702
|
+
|
|
703
|
+
### Deferred
|
|
704
|
+
|
|
705
|
+
Per-presentation style overrides, popover and exit-intent trigger modes, styled shadcn `Dialog` and `Sheet` wrappers (coming with the visual pass), and a React portal for the overlay DOM node (overlays currently use CSS `position: fixed`) are all planned for a later release.
|
|
706
|
+
|
|
707
|
+
## Answer recall and prefill
|
|
708
|
+
|
|
709
|
+
### Recall (piping)
|
|
710
|
+
|
|
711
|
+
Recall pipes a previously captured answer into any displayed text using `{{ fieldName }}` and `{{ fieldName|fallback }}` tokens. Tokens resolve against the current form values at render time. They work in field labels, field descriptions, option labels, and the success message.
|
|
712
|
+
|
|
713
|
+
Values are formatted through the field type's `format` function before substitution: a `select` field shows its option label, a `checkbox` shows Yes or No, a `date` shows a localized string. Token-free text is returned unchanged, so recall is a no-op when there are no tokens and can be applied unconditionally.
|
|
714
|
+
|
|
715
|
+
A simple two-field example where a later field echoes back an earlier answer:
|
|
716
|
+
|
|
717
|
+
```ts
|
|
718
|
+
// field 1: name (text)
|
|
719
|
+
// field 2: greeting (text, label uses recall)
|
|
720
|
+
{
|
|
721
|
+
label: 'Hello {{name}}, what can we help with?',
|
|
722
|
+
successMessage: 'Thanks {{name}}, we will be in touch.',
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
For custom layouts that manage their own rendering, `buildRecallResolver` and `interpolate` are exported from both `@10x-media/form-builder` and `@10x-media/form-builder/react`:
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
import { buildRecallResolver, interpolate } from '@10x-media/form-builder/react'
|
|
730
|
+
|
|
731
|
+
const resolve = buildRecallResolver({ fields, values, registry, locale, t })
|
|
732
|
+
|
|
733
|
+
const label = interpolate('Hello {{name}}', resolve)
|
|
734
|
+
const success = interpolate('Thanks {{name}}, we will be in touch.', resolve)
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
`buildRecallResolver` is pure and isomorphic -- no React, no DOM -- so it can also run in server components or API routes.
|
|
738
|
+
|
|
739
|
+
Deferred: recall inside email and action templates lands with the action pipeline. Calculations and scoring are their own phase.
|
|
740
|
+
|
|
741
|
+
### URL prefill
|
|
742
|
+
|
|
743
|
+
`valuesFromSearchParams` maps URL query parameters to typed initial field values. It maps KNOWN fields only, coerces each value to the field's value kind (number, boolean, multi-value, or text), and silently ignores unknown params, denied fields, and invalid values (for example a non-numeric string for a number field). Prefilled values are never trusted -- they still validate on submit.
|
|
744
|
+
|
|
745
|
+
Pass the result to `<Form initialValues={...}>`:
|
|
746
|
+
|
|
747
|
+
```tsx
|
|
748
|
+
import { valuesFromSearchParams } from '@10x-media/form-builder/react'
|
|
749
|
+
import { Form } from '@10x-media/form-builder/react'
|
|
750
|
+
|
|
751
|
+
// Server component -- reads searchParams on the server, no window access, no hydration mismatch.
|
|
752
|
+
export default async function ContactPage({
|
|
753
|
+
searchParams,
|
|
754
|
+
}: {
|
|
755
|
+
searchParams: Promise<Record<string, string>>
|
|
756
|
+
}) {
|
|
757
|
+
const form = await fetchForm('contact')
|
|
758
|
+
const params = new URLSearchParams(await searchParams)
|
|
759
|
+
const initialValues = valuesFromSearchParams(params, form.fields, registry)
|
|
760
|
+
|
|
761
|
+
return <Form form={form} initialValues={initialValues} />
|
|
762
|
+
}
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
The optional `options` argument controls which params map to which fields:
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
valuesFromSearchParams(params, fields, registry, {
|
|
769
|
+
map: { utm_email: 'email' }, // rename: ?utm_email=... prefills the `email` field
|
|
770
|
+
allow: ['name', 'email'], // only these field names may be prefilled
|
|
771
|
+
deny: ['internalRef'], // these field names are never prefilled
|
|
772
|
+
})
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
`map`, `allow`, and `deny` may be combined. `allow` is evaluated after `map`, so the mapped field name (not the param name) must appear in the allow list.
|
|
776
|
+
|
|
777
|
+
### Hidden context fields
|
|
778
|
+
|
|
779
|
+
Any field can be marked hidden in the admin UI via the Advanced section of its field settings. A hidden field is captured and included in the submission but not shown to the visitor. Pair it with URL prefill to capture tracking values like `utm_source` or referrer without displaying them in the form:
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
// The `source` field is hidden and prefilled from ?source=...
|
|
783
|
+
valuesFromSearchParams(params, fields, registry, { allow: ['source'] })
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
The `hidden` flag is render-only. The server stores and validates the field normally. A hidden field that is required and not prefilled will fail validation on submit, so hidden fields should be optional or reliably prefilled.
|
|
787
|
+
|
|
788
|
+
## Calculations and scoring
|
|
789
|
+
|
|
790
|
+
A `calculation` field is a read-only numeric field whose value is derived from a serializable expression tree over other answers. The value is computed live client-side for display, then re-computed authoritatively on the server at submit. The server value is never taken from the client; it is the authoritative value stored in the submission, used in conditions, and checked by validation rules.
|
|
791
|
+
|
|
792
|
+
Set `calcDisplay: false` to compute without rendering anything visible (for example a hidden total or quiz score that conditions and validation reference but the visitor does not see).
|
|
793
|
+
|
|
794
|
+
### The `CalcExpression` AST
|
|
795
|
+
|
|
796
|
+
Expressions are plain JSON objects with a `type` discriminant. No `eval`, no `Function`, no untrusted code executes at any point.
|
|
797
|
+
|
|
798
|
+
| Node type | Shape | Notes |
|
|
799
|
+
|---|---|---|
|
|
800
|
+
| `lit` | `{ type:'lit', value:number }` | Numeric literal |
|
|
801
|
+
| `ref` | `{ type:'ref', field:string }` | Reference to another field's answer |
|
|
802
|
+
| `op` | `{ type:'op', op:'+'\|'-'\|'*'\|'/'\|'%', left:CalcExpression, right:CalcExpression }` | Binary arithmetic |
|
|
803
|
+
| `neg` | `{ type:'neg', operand:CalcExpression }` | Unary negation |
|
|
804
|
+
| `fn` | `{ type:'fn', fn:'min'\|'max'\|'round'\|'abs'\|'ceil'\|'floor', args:CalcExpression[] }` | Math functions |
|
|
805
|
+
| `weight` | `{ type:'weight', field:string, weights:Record<string,number> }` | Maps an option answer to a score |
|
|
806
|
+
|
|
807
|
+
A price total multiplying two referenced answers:
|
|
808
|
+
|
|
809
|
+
```ts
|
|
810
|
+
import type { CalcExpression } from '@10x-media/form-builder'
|
|
811
|
+
|
|
812
|
+
const total: CalcExpression = {
|
|
813
|
+
type: 'op',
|
|
814
|
+
op: '*',
|
|
815
|
+
left: { type: 'ref', field: 'qty' },
|
|
816
|
+
right: { type: 'ref', field: 'unitPrice' },
|
|
817
|
+
}
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
A quiz score that maps a multiple-choice answer to a point value:
|
|
821
|
+
|
|
822
|
+
```ts
|
|
823
|
+
const scoreQ1: CalcExpression = {
|
|
824
|
+
type: 'weight',
|
|
825
|
+
field: 'q1',
|
|
826
|
+
weights: { a: 10, b: 0, c: 5 },
|
|
827
|
+
}
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
### Safety and totality
|
|
831
|
+
|
|
832
|
+
The evaluator is total: it always produces a finite number. Division or modulo by zero returns `0`; a missing `ref` returns `0`; unknown option keys in `weight` return `0`. Recursion is depth-guarded (max depth 64) so a pathologically nested expression cannot overflow the stack. The evaluator runs on the public submit path for every form submission.
|
|
833
|
+
|
|
834
|
+
### Dual compute
|
|
835
|
+
|
|
836
|
+
The `calculation` value is computed client-side each time its referenced answers change, so the running total or score is immediately visible to the visitor. On submit, the server re-computes from scratch using the same `evaluateCalc` engine. The client value is discarded; only the server result is stored.
|
|
837
|
+
|
|
838
|
+
Because the value is authoritative at the server level, it can be referenced in `visibleWhen`/`validateWhen` conditions and in validation rules exactly like any other field value.
|
|
839
|
+
|
|
840
|
+
### Scored results via recall
|
|
841
|
+
|
|
842
|
+
Pipe a computed score into the post-submit success message using the recall token syntax:
|
|
843
|
+
|
|
844
|
+
```
|
|
845
|
+
Thanks, you scored {{score}} points!
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
`{{score}}` resolves to the `score` calculation field's formatted value at display time. This reuses the same recall feature described in the Answer recall section above; no additional setup is needed.
|
|
849
|
+
|
|
850
|
+
### Authoring and programmatic use
|
|
851
|
+
|
|
852
|
+
Calculation expressions are authored as data (JSON stored in the field config). A visual calc-builder UI is planned for a later release.
|
|
853
|
+
|
|
854
|
+
`evaluateCalc`, `computeCalcFields`, `calcExpressionOf`, and `normalizeCalc` are exported from the root entry point and from `@10x-media/form-builder/react` so custom layouts can compute calc values with the exact same engine:
|
|
855
|
+
|
|
856
|
+
```ts
|
|
857
|
+
import { evaluateCalc, computeCalcFields, type CalcExpression } from '@10x-media/form-builder'
|
|
858
|
+
|
|
859
|
+
const expr: CalcExpression = { type: 'ref', field: 'qty' }
|
|
860
|
+
const result = evaluateCalc(expr, { qty: 3 }) // 3
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
```ts
|
|
864
|
+
import { computeCalcFields } from '@10x-media/form-builder/react'
|
|
865
|
+
|
|
866
|
+
const updatedValues = computeCalcFields(fields, currentValues)
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
## Post-submit actions and events
|
|
870
|
+
|
|
871
|
+
### Action pipeline
|
|
872
|
+
|
|
873
|
+
When a form submission is saved, the plugin runs the form's configured action blocks in order. Three built-in action types ship out of the box:
|
|
874
|
+
|
|
875
|
+
- **emailTeam**: sends an email to one or more addresses via `payload.sendEmail`. Subject and body are recall templates (`{{field}}`); for example, `"New submission: {{name}}"` resolves against the submission values. The plugin calls `payload.sendEmail` directly -- configure a Payload email adapter to make it work; when no adapter is configured it no-ops gracefully.
|
|
876
|
+
- **confirmation**: sends a confirmation email to the submitter. Same recall-template syntax.
|
|
877
|
+
- **signedWebhook**: POSTs the submission as JSON to a URL with an HMAC-SHA256 signature header. See Signed webhook below.
|
|
878
|
+
|
|
879
|
+
Custom action types are registered via `defineAction`:
|
|
880
|
+
|
|
881
|
+
```ts
|
|
882
|
+
import { defineAction } from '@10x-media/form-builder'
|
|
883
|
+
import type { TextField } from 'payload'
|
|
884
|
+
|
|
885
|
+
const slackNotify = defineAction({
|
|
886
|
+
type: 'slackNotify',
|
|
887
|
+
label: 'Notify Slack channel',
|
|
888
|
+
config: [
|
|
889
|
+
{ name: 'webhookUrl', type: 'text', required: true } as TextField,
|
|
890
|
+
{ name: 'message', type: 'text' } as TextField,
|
|
891
|
+
],
|
|
892
|
+
run: async ({ values, config }) => {
|
|
893
|
+
const body = config.message ?? values.map((v) => `${v.label}: ${v.value}`).join('\n')
|
|
894
|
+
await fetch(config.webhookUrl as string, {
|
|
895
|
+
method: 'POST',
|
|
896
|
+
headers: { 'Content-Type': 'application/json' },
|
|
897
|
+
body: JSON.stringify({ text: body }),
|
|
898
|
+
})
|
|
899
|
+
},
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
formBuilder({ actions: { slackNotify } })
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
The `config` fields become an authoring block inside the form editor. `run` receives the submission values, the stored config, `payload`, and a translate helper.
|
|
906
|
+
|
|
907
|
+
### Plugin option and per-form blocks
|
|
908
|
+
|
|
909
|
+
The plugin accepts an `actions` option following the same `false | true | object` convention as fields and validations:
|
|
910
|
+
|
|
911
|
+
```ts
|
|
912
|
+
formBuilder({
|
|
913
|
+
actions: {
|
|
914
|
+
emailTeam: true, // keep built-in
|
|
915
|
+
confirmation: false, // remove built-in
|
|
916
|
+
slackNotify, // add custom
|
|
917
|
+
},
|
|
918
|
+
})
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
Forms then expose an **Actions** blocks array in the admin. Editors add one or more action blocks per form and fill in each block's `config` fields. Actions run in the order they appear.
|
|
922
|
+
|
|
923
|
+
### Run model
|
|
924
|
+
|
|
925
|
+
Actions run as Payload jobs when a job runner is present (`config.jobs.autoRun: true` or the `@10x-media/jobs` plugin). When no job runner is detected, a bounded inline fallback runs them synchronously after the submission row is stored. Either way, an action error never fails the submission: each action is isolated, and a failure is captured as a failed `ActionResult` and logged rather than re-thrown.
|
|
926
|
+
|
|
927
|
+
### Signed webhook
|
|
928
|
+
|
|
929
|
+
The `signedWebhook` action POSTs the submission body as JSON and attaches an `X-Form-Signature` header:
|
|
930
|
+
|
|
931
|
+
```
|
|
932
|
+
X-Form-Signature: v1=<hex>
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
The signature is HMAC-SHA256 over the raw JSON body (the body alone, not a timestamp-prefixed string). To verify on the receiving end:
|
|
936
|
+
|
|
937
|
+
```ts
|
|
938
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
939
|
+
|
|
940
|
+
function verifyFormSignature(rawBody: string, secret: string, header: string): boolean {
|
|
941
|
+
const expected = `v1=${createHmac('sha256', secret).update(rawBody).digest('hex')}`
|
|
942
|
+
const a = Buffer.from(header)
|
|
943
|
+
const b = Buffer.from(expected)
|
|
944
|
+
// Constant-time compare (avoid a timing side-channel); length guard first since timingSafeEqual requires equal lengths.
|
|
945
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
946
|
+
}
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
This header is intentionally distinct from `@10x-media/webhooks`' `X-Webhook-Signature` (which signs `timestamp.body`) so a receiver cannot mistake the two schemes.
|
|
950
|
+
|
|
951
|
+
`signPayload` and `SIGNATURE_HEADER` are exported from the root entry point for consumers who need to verify manually.
|
|
952
|
+
|
|
953
|
+
### Lifecycle events
|
|
954
|
+
|
|
955
|
+
The plugin emits typed lifecycle events through the `events` sink (a `FormEventSink` passed to `formBuilder({ events })`). The sink is a no-op by default; analytics adapters and the future automations plugin subscribe here.
|
|
956
|
+
|
|
957
|
+
The full event taxonomy:
|
|
958
|
+
|
|
959
|
+
| Event | Emitter | When |
|
|
960
|
+
|---|---|---|
|
|
961
|
+
| `form.viewed` | renderer (client) | form mounts |
|
|
962
|
+
| `form.started` | renderer (client) | first field interaction |
|
|
963
|
+
| `step.viewed` | renderer (client) | multi-step: step becomes active |
|
|
964
|
+
| `step.completed` | renderer (client) | multi-step: step passes validation |
|
|
965
|
+
| `field.errored` | renderer (client) | a field receives a validation error |
|
|
966
|
+
| `form.abandoned` | renderer (client) | component unmounts without a successful submission |
|
|
967
|
+
| `submission.created` | server | submission row saved and actions dispatched |
|
|
968
|
+
|
|
969
|
+
`form.viewed` through `form.abandoned` are emitted by the `<Form>` component. `submission.created` is emitted by the server `afterChange` hook.
|
|
970
|
+
|
|
971
|
+
Types are exported from `@10x-media/form-builder/types`:
|
|
972
|
+
|
|
973
|
+
```ts
|
|
974
|
+
import type { FormEvent, FormEventSink } from '@10x-media/form-builder/types'
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
### Deferred
|
|
978
|
+
|
|
979
|
+
Conditional notifications (only send an action when a condition is met) are planned for v1.x.
|
|
980
|
+
|
|
981
|
+
## Consent
|
|
982
|
+
|
|
983
|
+
The consent field captures affirmative, checkbox-based consent with a built-in compliance baseline: unchecked by default, required to submit unless `optional: true` is set, and proof stored by reference -- never by copying policy text.
|
|
984
|
+
|
|
985
|
+
### Compliance defaults
|
|
986
|
+
|
|
987
|
+
- The checkbox is unchecked by default. The form will not submit until every non-optional consent field is checked.
|
|
988
|
+
- Set `optional: true` on a consent field for marketing opt-ins or similar non-required consent. Keep terms-of-service consent separate and required.
|
|
989
|
+
- Proof is stored as a structured object on the submission's `consent` JSON array, containing the agreed boolean, a URL reference (`ref`), and an optional version reference (`versionRef`). Policy text is never stored.
|
|
990
|
+
|
|
991
|
+
### Consent field
|
|
992
|
+
|
|
993
|
+
The `consent` field type carries a `statement` (the text shown next to the checkbox), an optional `source` (which consent source type resolves the policy link), and a `sourceConfig` group for source-specific parameters:
|
|
994
|
+
|
|
995
|
+
```ts
|
|
996
|
+
// In a form's fields blocks:
|
|
997
|
+
{
|
|
998
|
+
blockType: 'consent',
|
|
999
|
+
name: 'terms',
|
|
1000
|
+
statement: 'I have read and agree to the Privacy Policy',
|
|
1001
|
+
source: 'static',
|
|
1002
|
+
sourceConfig: {
|
|
1003
|
+
label: 'Privacy Policy',
|
|
1004
|
+
url: 'https://example.com/privacy',
|
|
1005
|
+
},
|
|
1006
|
+
optional: false,
|
|
1007
|
+
}
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
### Consent sources
|
|
1011
|
+
|
|
1012
|
+
Three built-in source types ship:
|
|
1013
|
+
|
|
1014
|
+
- **static** (default): a fixed URL and label, authored directly in `sourceConfig`. No server step required; the renderer reads `sourceConfig` directly.
|
|
1015
|
+
- **pageReference**: resolves a policy URL at display time by looking up a Payload collection document. Set `relationTo`, `docId`, and optionally `urlField` in `sourceConfig`.
|
|
1016
|
+
- **custom** via `defineConsentSource`: implement a `resolve` function that returns `{ links, versionRef?, versionLabel? }` given the stored config and a Payload instance.
|
|
1017
|
+
|
|
1018
|
+
```ts
|
|
1019
|
+
import { defineConsentSource } from '@10x-media/form-builder'
|
|
1020
|
+
|
|
1021
|
+
const mySource = defineConsentSource({
|
|
1022
|
+
type: 'mySource',
|
|
1023
|
+
label: 'My policy source',
|
|
1024
|
+
resolve: async ({ config, payload, locale }) => ({
|
|
1025
|
+
links: [{ label: config.label as string, url: config.url as string }],
|
|
1026
|
+
}),
|
|
1027
|
+
})
|
|
1028
|
+
|
|
1029
|
+
formBuilder({ consentSources: { mySource } })
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
The `consentSources` option follows the same `false | true | object` convention used by `fields`, `rules`, and `actions`.
|
|
1033
|
+
|
|
1034
|
+
### Version capture
|
|
1035
|
+
|
|
1036
|
+
Set `captureVersion: true` in `sourceConfig` for sources that support it (currently `pageReference`). When enabled, `captureConsent` calls `resolvePublishedVersionRef` at submit time to record the version of the policy document the user agreed to. If Payload's draft/versions feature is off for that collection, `versionRef` is null and omitted from the proof.
|
|
1037
|
+
|
|
1038
|
+
```ts
|
|
1039
|
+
import { resolvePublishedVersionRef } from '@10x-media/form-builder'
|
|
1040
|
+
|
|
1041
|
+
const ref = await resolvePublishedVersionRef(payload, {
|
|
1042
|
+
collection: 'pages',
|
|
1043
|
+
id: docId,
|
|
1044
|
+
})
|
|
1045
|
+
```
|
|
1046
|
+
|
|
1047
|
+
### Proof by reference
|
|
1048
|
+
|
|
1049
|
+
On submit, `captureConsent` is called for each consent field. It returns a `ConsentProof` stored in the submission's `consent` array:
|
|
1050
|
+
|
|
1051
|
+
```ts
|
|
1052
|
+
import type { ConsentProof } from '@10x-media/form-builder'
|
|
1053
|
+
|
|
1054
|
+
// Stored per consent field:
|
|
1055
|
+
// { agreed: true, ref: 'https://...', versionRef?: '...', at: '<ISO-8601>' }
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
`ref` is the policy URL resolved at submit time, not at page load. `at` is the ISO-8601 timestamp of the submission. Policy text is never stored.
|
|
1059
|
+
|
|
1060
|
+
### Display step: resolveConsentLinks
|
|
1061
|
+
|
|
1062
|
+
For the `pageReference` source (and any custom source), call `resolveConsentLinks` server-side before rendering so the renderer receives the resolved policy links:
|
|
1063
|
+
|
|
1064
|
+
```ts
|
|
1065
|
+
import { resolveConsentLinks } from '@10x-media/form-builder'
|
|
1066
|
+
|
|
1067
|
+
const resolved = await resolveConsentLinks(field, {
|
|
1068
|
+
registry: consentRegistry,
|
|
1069
|
+
payload,
|
|
1070
|
+
locale: 'en',
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
// Embed `resolved.links` into the field as `consentLinks` before passing to the renderer.
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
The `static` source needs no server step; the renderer falls back to `sourceConfig.url` and `sourceConfig.label` directly when `consentLinks` is absent.
|
|
1077
|
+
|
|
1078
|
+
### Renderer
|
|
1079
|
+
|
|
1080
|
+
The `consent` field is included in `defaultRenderers` (keyed `'consent'`). It renders:
|
|
1081
|
+
|
|
1082
|
+
- the `statement` as the field label,
|
|
1083
|
+
- an unchecked checkbox (the field value) that the user must check,
|
|
1084
|
+
- one or more policy links derived from `field.consentLinks` (server-resolved) or, as a fallback, from `field.sourceConfig.url`.
|
|
1085
|
+
|
|
1086
|
+
Policy links open in a new tab with `rel="noopener noreferrer"`.
|
|
1087
|
+
|
|
1088
|
+
```tsx
|
|
1089
|
+
import { Form, defaultRenderers, resolveRenderers } from '@10x-media/form-builder/react'
|
|
1090
|
+
|
|
1091
|
+
<Form form={form} renderers={resolveRenderers(defaultRenderers)} />
|
|
1092
|
+
```
|
|
1093
|
+
|
|
1094
|
+
### Deferred
|
|
1095
|
+
|
|
1096
|
+
Integration with c15t (consent management infrastructure) and retention-pruning of consent proofs are planned for a later release.
|
|
1097
|
+
|
|
1098
|
+
## Polls and response aggregation
|
|
1099
|
+
|
|
1100
|
+
Submissions are a collection, so aggregation is a query. This phase ships a submission-aggregation utility, a `<FormResults>` renderer (headless plus shadcn), a gated public results endpoint, and a turnkey `<Poll>` component. The same utility powers survey response summaries.
|
|
1101
|
+
|
|
1102
|
+
### Aggregation utility
|
|
1103
|
+
|
|
1104
|
+
`aggregateFieldResponses` (one field) and `aggregateFormResponses` (one or many fields, one pass) tally a form's submissions by answer value.
|
|
1105
|
+
|
|
1106
|
+
```ts
|
|
1107
|
+
import { aggregateFieldResponses } from '@10x-media/form-builder'
|
|
1108
|
+
|
|
1109
|
+
const results = await aggregateFieldResponses({ payload, formId, field: 'colour' })
|
|
1110
|
+
// {
|
|
1111
|
+
// field: 'colour',
|
|
1112
|
+
// label: 'Favourite colour',
|
|
1113
|
+
// fieldType: 'select',
|
|
1114
|
+
// total: 42, // respondents who answered this field (the percentage denominator)
|
|
1115
|
+
// truncated: false,
|
|
1116
|
+
// buckets: [
|
|
1117
|
+
// { value: 'red', label: 'Red', count: 28, percentage: 66.7 },
|
|
1118
|
+
// { value: 'blue', label: 'Blue', count: 14, percentage: 33.3 },
|
|
1119
|
+
// ],
|
|
1120
|
+
// }
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
- **Percentages are over respondents** (submissions with a non-empty answer for the field), not all submissions. Array answers (select-all) increment one bucket per element while counting one respondent, so percentages can sum past 100%.
|
|
1124
|
+
- **`complete` submissions only by default.** Pass `status: 'all'` to include partials, or `status: 'partial'`.
|
|
1125
|
+
- **Labels** come from the field's current options, falling back to the submitted snapshot label, then the raw value. Buckets follow the option order, then any retired values by count.
|
|
1126
|
+
- **Bounded.** It pages `payload.find` and reduces in JS (identical on Mongo and Postgres, no durable snapshot; long-term rollups are the analytics plugin's concern). The scan caps at `maxSubmissions` (default 10000); past it the result is flagged `truncated: true`.
|
|
1127
|
+
|
|
1128
|
+
`aggregateFormResponses` with no `fields` aggregates every enumerable field (those with options), which is the survey-summary case:
|
|
1129
|
+
|
|
1130
|
+
```ts
|
|
1131
|
+
const summary = await aggregateFormResponses({ payload, formId }) // FieldAggregation[]
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
### `<FormResults>`
|
|
1135
|
+
|
|
1136
|
+
A headless, presentational renderer. It never fetches: resolve the data server-side and pass it in. The option label, count, and percentage are real text (the accessible content); the bar fill is decorative. Import the optional `./styles.css` for the `fb-results*` bar styling, or style it yourself. A shadcn-styled parity component is available from the registry (`shadcn add` the `form-results` item).
|
|
1137
|
+
|
|
1138
|
+
```tsx
|
|
1139
|
+
import { FormResults } from '@10x-media/form-builder/react'
|
|
1140
|
+
|
|
1141
|
+
<FormResults results={results} /> // one field, or
|
|
1142
|
+
<FormResults results={summary} /> // an array (survey summary)
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
### Results endpoint and opt-in
|
|
1146
|
+
|
|
1147
|
+
Anonymous voters cannot read raw submissions (that access is gated, and submissions never reach the browser), so public poll results are served as aggregate counts through a gated endpoint:
|
|
1148
|
+
|
|
1149
|
+
```
|
|
1150
|
+
GET /api/forms/:id/results?field=<name> -> { results: FieldAggregation[] }
|
|
1151
|
+
```
|
|
1152
|
+
|
|
1153
|
+
- **Authenticated callers** (admins) may aggregate any field, or all enumerable fields when `field` is omitted.
|
|
1154
|
+
- **Anonymous callers** are served only when the form's `showResults` is on, only for the configured `resultsField`, and only if that field is enumerable (a choice field).
|
|
1155
|
+
- **Security:** set `resultsField` to a choice field, never a free-text or PII field. The enumerable check refuses to expose a non-choice field publicly even if `resultsField` is misconfigured.
|
|
1156
|
+
|
|
1157
|
+
`fetchFormResults` is the client helper:
|
|
1158
|
+
|
|
1159
|
+
```ts
|
|
1160
|
+
import { fetchFormResults } from '@10x-media/form-builder/react'
|
|
1161
|
+
|
|
1162
|
+
const res = await fetchFormResults({ formId, field: 'colour' })
|
|
1163
|
+
if (res.ok) {
|
|
1164
|
+
// res.results: FieldAggregation[]
|
|
1165
|
+
}
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
### `<Poll>`
|
|
1169
|
+
|
|
1170
|
+
A turnkey poll renders `<Form>` until the visitor votes, then fetches results and shows `<FormResults>`. A per-browser localStorage flag skips straight to results on revisit.
|
|
1171
|
+
|
|
1172
|
+
```tsx
|
|
1173
|
+
import { Poll } from '@10x-media/form-builder/react'
|
|
1174
|
+
|
|
1175
|
+
<Poll form={form} resultsField="colour" />
|
|
1176
|
+
```
|
|
1177
|
+
|
|
1178
|
+
For an SSR poll, aggregate server-side and choose what to render:
|
|
1179
|
+
|
|
1180
|
+
```tsx
|
|
1181
|
+
// app/poll/[id]/page.tsx (server component)
|
|
1182
|
+
import { aggregateFieldResponses } from '@10x-media/form-builder'
|
|
1183
|
+
import { FormResults, Form } from '@10x-media/form-builder/react'
|
|
1184
|
+
|
|
1185
|
+
export default async function PollPage({ params }) {
|
|
1186
|
+
const form = await payload.findByID({ collection: 'forms', id: params.id })
|
|
1187
|
+
const voted = (await cookies()).has(`voted-${form.id}`)
|
|
1188
|
+
if (voted) {
|
|
1189
|
+
const results = await aggregateFieldResponses({ payload, formId: form.id, field: 'colour' })
|
|
1190
|
+
return <FormResults results={results ? [results] : []} />
|
|
1191
|
+
}
|
|
1192
|
+
return <Form form={form} />
|
|
1193
|
+
}
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
### One response per identity (dedup)
|
|
1197
|
+
|
|
1198
|
+
The `<Poll>` localStorage guard is per-browser UX: it stops the same browser re-seeing the form, but it is bypassable and is not integrity. Server-enforced one-per-identity dedup composes with what already ships:
|
|
1199
|
+
|
|
1200
|
+
- **Authenticated forms:** gate on `req.user` in a submissions hook or access rule.
|
|
1201
|
+
- **By a field value (such as email):** the built-in `notAlreadySubmitted` validation rule.
|
|
1202
|
+
|
|
1203
|
+
Cookie and IP identity dedup arrives with the spam phase.
|
|
1204
|
+
|
|
1205
|
+
## File uploads
|
|
1206
|
+
|
|
1207
|
+
A `file` field backed by a configurable upload collection. The client uploads to the collection and submits only the upload id; the server re-reads the file metadata from the stored doc and enforces MIME/size, so the client is never trusted for it.
|
|
1208
|
+
|
|
1209
|
+
### The `file` field
|
|
1210
|
+
|
|
1211
|
+
```ts
|
|
1212
|
+
{
|
|
1213
|
+
blockType: 'file',
|
|
1214
|
+
name: 'resume',
|
|
1215
|
+
label: 'Resume',
|
|
1216
|
+
required: true,
|
|
1217
|
+
relationTo: 'form-uploads', // upload collection (default: form-uploads)
|
|
1218
|
+
mimeTypes: ['application/pdf', 'image/*'],
|
|
1219
|
+
maxSize: 5_000_000, // bytes
|
|
1220
|
+
}
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
The submitted value is a self-describing `FileRef` snapshot, captured server-side:
|
|
1224
|
+
|
|
1225
|
+
```ts
|
|
1226
|
+
type FileRef = { id: string | number; filename: string; mimeType: string; filesize: number; url?: string }
|
|
1227
|
+
```
|
|
1228
|
+
|
|
1229
|
+
### Upload collection (the `uploads` option)
|
|
1230
|
+
|
|
1231
|
+
The plugin ships a built-in `form-uploads` collection, on by default. It allows anonymous create (public forms upload here) and gates read/update/delete to authenticated users.
|
|
1232
|
+
|
|
1233
|
+
```ts
|
|
1234
|
+
formBuilder({
|
|
1235
|
+
uploads: true, // default: ship form-uploads
|
|
1236
|
+
// uploads: false, // bring your own; set the file field's relationTo to it
|
|
1237
|
+
// uploads: { slug, upload: { staticDir, mimeTypes }, access, fields }, // configure the built-in
|
|
1238
|
+
})
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
Storage is the project's responsibility: configure `upload.staticDir` or a Payload storage adapter (S3, etc.). The collection has no `imageSizes`, so it needs no `sharp`.
|
|
1242
|
+
|
|
1243
|
+
### Server enforcement (the trust boundary)
|
|
1244
|
+
|
|
1245
|
+
On submit, `runSubmission` loads the referenced upload doc and re-validates it against the field's `mimeTypes`/`maxSize`, capturing the authoritative `FileRef`. A missing, disallowed, or oversize file blocks the submission with a per-field error; required presence rides the normal required rule. The client-sent filename/mimeType/filesize are never stored. Set `resultsField`-style PII care here too: only the upload id crosses the wire.
|
|
1246
|
+
|
|
1247
|
+
### Renderer and client helper
|
|
1248
|
+
|
|
1249
|
+
The headless `file` renderer (and its shadcn parity) is a file input that uploads on change and stores the returned id; `uploadFile` is the underlying client helper:
|
|
1250
|
+
|
|
1251
|
+
```tsx
|
|
1252
|
+
import { uploadFile } from '@10x-media/form-builder/react'
|
|
1253
|
+
|
|
1254
|
+
const result = await uploadFile({ file, collection: 'form-uploads' })
|
|
1255
|
+
if (result.ok) {
|
|
1256
|
+
// result.id is the upload id to store as the field value
|
|
1257
|
+
}
|
|
1258
|
+
```
|
|
1259
|
+
|
|
1260
|
+
The admin answers view renders a file answer as a download link when the `FileRef` carries a url.
|
|
1261
|
+
|
|
1262
|
+
### Deferred
|
|
1263
|
+
|
|
1264
|
+
Multiple files per field is a v1.x follow-up. Per-upload ownership scoping and rate-limiting on the public upload path now ship with the spam controls below.
|
|
1265
|
+
|
|
1266
|
+
## Spam and abuse prevention
|
|
1267
|
+
|
|
1268
|
+
A honeypot decoy and per-identity rate limiting are **on by default** on the public submission and upload paths, with a captcha adapter seam and server-stamped upload ownership. Everything is opt-out, per control or with `spam: false`.
|
|
1269
|
+
|
|
1270
|
+
```ts
|
|
1271
|
+
formBuilder({
|
|
1272
|
+
spam: {
|
|
1273
|
+
// honeypot: false, // disable the decoy
|
|
1274
|
+
// rateLimit: false, // disable submission rate limiting
|
|
1275
|
+
// rateLimit: { window: 60_000, max: 5 },
|
|
1276
|
+
// uploadRateLimit: { window: 60_000, max: 20 },
|
|
1277
|
+
// captcha: turnstileProvider, // a defineCaptchaProvider adapter
|
|
1278
|
+
// identify: (req) => req.user ? `user:${req.user.id}` : null,
|
|
1279
|
+
// ipHeader: 'cf-connecting-ip', // trusted client-IP header
|
|
1280
|
+
// metadata: { ip: true, ua: true }, // opt in to storing IP/UA (privacy)
|
|
1281
|
+
},
|
|
1282
|
+
})
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
### Defense in depth, not DoS protection
|
|
1286
|
+
|
|
1287
|
+
App-level rate limiting **complements** edge/CDN/WAF rate limiting; it does not replace it. Treat it as one layer: it cheaply turns away casual abuse and bots, but a determined flood should be stopped at the edge. The default limiter is a window counter over Payload's first-class `payload.kv` (durable + cross-instance). Because the KV interface has no atomic increment, the counter is a **soft limit** (a concurrent burst can slightly exceed the cap). Swap `spam.rateLimit.limiter` for a Redis-backed `RateLimiter` if you need a hard limit.
|
|
1288
|
+
|
|
1289
|
+
### Best-effort identity
|
|
1290
|
+
|
|
1291
|
+
Payload v3 has no `req.ip`. Identity resolves to the authenticated user id, else the first hop of a trusted IP header (default `x-forwarded-for`). This is **proxy-dependent**: set `spam.ipHeader` to whatever your proxy/CDN sets, or provide a custom `spam.identify(req)` (e.g. keyed on a signed cookie). When no identity can be resolved, rate limiting and upload-ownership scoping **fail open** rather than blocking legitimate traffic, so configure your proxy for these controls to bite.
|
|
1292
|
+
|
|
1293
|
+
### Honeypot
|
|
1294
|
+
|
|
1295
|
+
`<Form>` renders a visually hidden decoy field (off-screen, `aria-hidden`, `tabIndex={-1}`, `autoComplete="off"`). A real user never fills it; a bot that fills every input does, and the server rejects the submission with a generic error. The decoy field name defaults to `confirm_email` (shared constant `DEFAULT_HONEYPOT_FIELD`); if you customize `spam.honeypot.fieldName` on the server, pass the same name to `<Form honeypot={{ name }}>`, and make sure it does not collide with a real field's machine name.
|
|
1296
|
+
|
|
1297
|
+
### Captcha
|
|
1298
|
+
|
|
1299
|
+
v1 ships the **seam only**, no built-in provider. Supply one and pass the widget token to `<Form>`:
|
|
1300
|
+
|
|
1301
|
+
```ts
|
|
1302
|
+
import { defineCaptchaProvider } from '@10x-media/form-builder'
|
|
1303
|
+
|
|
1304
|
+
const turnstile = defineCaptchaProvider({
|
|
1305
|
+
type: 'turnstile',
|
|
1306
|
+
verify: async ({ token }) => {
|
|
1307
|
+
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
|
1308
|
+
method: 'POST',
|
|
1309
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1310
|
+
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET, response: token }),
|
|
1311
|
+
})
|
|
1312
|
+
return ((await res.json()) as { success: boolean }).success
|
|
1313
|
+
},
|
|
1314
|
+
})
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
```tsx
|
|
1318
|
+
<Form form={form} captchaToken={tokenFromYourWidget} />
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
When a provider is configured, a submission without a valid token is rejected. Prebuilt Turnstile/reCAPTCHA/hCaptcha adapters are a v1.x addition.
|
|
1322
|
+
|
|
1323
|
+
### Upload ownership
|
|
1324
|
+
|
|
1325
|
+
Each upload to the built-in `form-uploads` collection is stamped with the uploader's identity (`owner`). At submit, a file reference is captured only if the submitting identity matches the upload's owner, so an anonymous submitter cannot reference another identity's upload; a mismatch is treated as a missing file. When the submitter cannot be identified (no resolvable identity), ownership is not enforced (fail-open) -- a proxy-configured deployment identifies every request, so this only relaxes scoping where it could not apply fairly anyway. Unstamped uploads (no identity at upload time, or a bring-your-own collection without the field) are unaffected. Ownership granularity is identity-level (IP for anonymous traffic): clients sharing a NAT share scope, and an identity that changes between upload and submit (a rotating mobile IP) will not match its own earlier upload. A per-session token is a v1.x option.
|
|
1326
|
+
|
|
1327
|
+
### Capture metadata (privacy)
|
|
1328
|
+
|
|
1329
|
+
The submission `meta` always records a timestamp and a spam signal. The client IP and user-agent are **not** stored unless you opt in via `spam.metadata.ip` / `spam.metadata.ua` (a GDPR consideration).
|
|
1330
|
+
|
|
1331
|
+
### Spam options
|
|
1332
|
+
|
|
1333
|
+
| Option | Type | Default | Description |
|
|
1334
|
+
|---|---|---|---|
|
|
1335
|
+
| `honeypot` | `false \| { fieldName? }` | on (`confirm_email`) | Hidden decoy field; a filled decoy is rejected. |
|
|
1336
|
+
| `rateLimit` | `false \| { window?, max?, limiter? }` | on (60s / 5) | Per-identity submission limit. |
|
|
1337
|
+
| `uploadRateLimit` | `false \| { window?, max?, limiter? }` | on (60s / 20) | Per-identity upload limit. |
|
|
1338
|
+
| `captcha` | `CaptchaProvider` | none | A `defineCaptchaProvider` adapter; token verified on submit. |
|
|
1339
|
+
| `identify` | `IdentifyFn` | user id / IP header | Identity resolver for limiting + ownership. |
|
|
1340
|
+
| `ipHeader` | `string` | `x-forwarded-for` | Trusted client-IP header. |
|
|
1341
|
+
| `metadata` | `{ ip?, ua? }` | `{}` (off) | Opt in to storing IP/UA on the submission `meta`. |
|
|
1342
|
+
|
|
1343
|
+
## Accessibility
|
|
1344
|
+
|
|
1345
|
+
The headless renderer ships accessible defaults, verified by automated axe checks (`axe-core` over the rendered `<Form>` in the unit tier, plus a real-browser `@axe-core/playwright` sweep in e2e). The contract every built-in renderer upholds:
|
|
1346
|
+
|
|
1347
|
+
- Every field control has a programmatically associated label (`<label for>` / accessible name); the consent control falls back to the field label so it is never unlabelled.
|
|
1348
|
+
- Validation errors are exposed via `role="alert"`, linked to the control with `aria-describedby`, and the control is marked `aria-invalid` when invalid.
|
|
1349
|
+
- Submit, Next, and Back are real `<button>`s; the multi-step flow validates per step on advance.
|
|
1350
|
+
- The honeypot decoy is `aria-hidden` and off the tab order, so assistive tech never encounters it.
|
|
1351
|
+
|
|
1352
|
+
Caveat (by design): we ship accessible structure, but final compliance also depends on your theme. Color contrast in particular is a function of the colors you apply, so the jsdom axe pass skips `color-contrast` (no layout engine) and the e2e sweep checks it against the demo theme; validate contrast against your own palette. If you supply custom renderers (`defineFieldRenderer`) or restyle the primitives, preserve the label/error wiring above. See [A11y checklist for custom renderers](#a11y-checklist-for-custom-renderers).
|
|
1353
|
+
|
|
1354
|
+
## Requirements
|
|
1355
|
+
|
|
1356
|
+
- Payload v3 (peer: `payload@^3.82.0`)
|
|
1357
|
+
- React 19 (peer: `react@^19.0.0`, `react-dom@^19.0.0`)
|
|
1358
|
+
- `@payloadcms/ui@^3.82.0` (peer)
|
|
1359
|
+
|
|
1360
|
+
## Installation
|
|
1361
|
+
|
|
1362
|
+
```bash
|
|
1363
|
+
pnpm add @10x-media/form-builder
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
## Usage
|
|
1367
|
+
|
|
1368
|
+
```ts
|
|
1369
|
+
import { buildConfig } from 'payload'
|
|
1370
|
+
import { formBuilder } from '@10x-media/form-builder'
|
|
1371
|
+
|
|
1372
|
+
export default buildConfig({
|
|
1373
|
+
plugins: [formBuilder({})],
|
|
1374
|
+
})
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
Add a custom field type with `defineFormField`. The `value` kind types the value passed to `validate` and `format`. Field types are authored with precise generics, so cast each registry entry to `FieldTypeOption` (the registry stores the erased type, the same boundary the built-ins use):
|
|
1378
|
+
|
|
1379
|
+
```ts
|
|
1380
|
+
import { buildConfig } from 'payload'
|
|
1381
|
+
import { formBuilder, defineFormField, type FieldTypeOption } from '@10x-media/form-builder'
|
|
1382
|
+
|
|
1383
|
+
export default buildConfig({
|
|
1384
|
+
plugins: [
|
|
1385
|
+
formBuilder({
|
|
1386
|
+
fields: {
|
|
1387
|
+
rating: defineFormField<'number'>({
|
|
1388
|
+
type: 'rating',
|
|
1389
|
+
label: 'Star rating',
|
|
1390
|
+
value: 'number',
|
|
1391
|
+
validate: ({ value }) => (value == null || value <= 5 ? true : 'Too high'),
|
|
1392
|
+
format: ({ value }) => `${value ?? 0} / 5`,
|
|
1393
|
+
}) as FieldTypeOption,
|
|
1394
|
+
},
|
|
1395
|
+
}),
|
|
1396
|
+
],
|
|
1397
|
+
})
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
Add a custom validation rule with `defineValidationRule`. Rules are authored with precise generics, so cast each registry entry to `ValidationRuleOption` at the `rules` boundary, the same way field types cast to `FieldTypeOption`:
|
|
1401
|
+
|
|
1402
|
+
```ts
|
|
1403
|
+
import { formBuilder, defineValidationRule, type ValidationRuleOption } from '@10x-media/form-builder'
|
|
1404
|
+
|
|
1405
|
+
formBuilder({
|
|
1406
|
+
rules: {
|
|
1407
|
+
even: defineValidationRule<Record<string, never>, number>({
|
|
1408
|
+
type: 'even',
|
|
1409
|
+
label: 'Even number',
|
|
1410
|
+
appliesTo: ['number'],
|
|
1411
|
+
defaultMessage: 'Must be an even number',
|
|
1412
|
+
validate: ({ value, message }) => (value == null || value % 2 === 0 ? true : message()),
|
|
1413
|
+
}) as ValidationRuleOption,
|
|
1414
|
+
},
|
|
1415
|
+
})
|
|
1416
|
+
```
|
|
1417
|
+
|
|
1418
|
+
## Options
|
|
1419
|
+
|
|
1420
|
+
| Option | Type | Default | Description |
|
|
1421
|
+
|---|---|---|---|
|
|
1422
|
+
| `disabled` | `boolean` | `false` | When `true`, returns the incoming config unchanged. Useful for toggling the plugin per environment. |
|
|
1423
|
+
| `fields` | `FieldTypesConfig` | `{}` | Per-type registry override. `false` removes a built-in, `true` keeps it, an object adds a new type or replaces one. |
|
|
1424
|
+
| `rules` | `ValidationRulesConfig` | `{}` | Per-rule registry override. `false` removes a built-in, `true` keeps it, an object adds a new rule or replaces one. |
|
|
1425
|
+
| `events` | `FormEventSink` | `undefined` | Pluggable sink for form lifecycle events; defaults to a no-op. Consumed by the submission pipeline in a later phase. |
|
|
1426
|
+
| `presentations` | `PresentationsDescriptorConfig` | `{}` | Per-presentation registry override (page/modal/drawer/inline + custom). `false` removes, `true` keeps, an object adds or replaces one. |
|
|
1427
|
+
| `actions` | `ActionsConfig` | `{}` | Per-action registry override (email/confirmation/signed-webhook + custom). `false` removes, `true` keeps, an object adds or replaces one. |
|
|
1428
|
+
| `consentSources` | `ConsentSourcesConfig` | `{}` | Per-consent-source registry override (static/pageReference + custom). `false` removes, `true` keeps, an object adds or replaces one. |
|
|
1429
|
+
| `uploads` | `UploadsOption` | `true` | Built-in `form-uploads` collection backing file fields. `false` brings your own (set the file field's `relationTo`); an object overrides slug/upload/access/fields. |
|
|
1430
|
+
| `spam` | `SpamOption` | `{}` (on) | Honeypot + rate-limiting (on by default), a captcha adapter seam, and upload-ownership scoping. `false` disables the whole subsystem; see [Spam and abuse prevention](#spam-and-abuse-prevention-spec-117). |
|
|
1431
|
+
|
|
1432
|
+
## License
|
|
1433
|
+
|
|
1434
|
+
[MIT](./LICENSE). Copyright 10x Media GmbH.
|