payload-dynamic-forms 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +461 -2
- package/dist/access.d.ts +7 -0
- package/dist/access.js +10 -0
- package/dist/access.js.map +1 -0
- package/dist/collections/attachments.d.ts +6 -0
- package/dist/collections/attachments.js +67 -0
- package/dist/collections/attachments.js.map +1 -0
- package/dist/collections/blocks.d.ts +27 -0
- package/dist/collections/blocks.js +495 -0
- package/dist/collections/blocks.js.map +1 -0
- package/dist/collections/forms.d.ts +3 -0
- package/dist/collections/forms.js +558 -0
- package/dist/collections/forms.js.map +1 -0
- package/dist/collections/overrides.d.ts +7 -0
- package/dist/collections/overrides.js +49 -0
- package/dist/collections/overrides.js.map +1 -0
- package/dist/collections/ruleFields.d.ts +6 -0
- package/dist/collections/ruleFields.js +97 -0
- package/dist/collections/ruleFields.js.map +1 -0
- package/dist/collections/submissions.d.ts +7 -0
- package/dist/collections/submissions.js +225 -0
- package/dist/collections/submissions.js.map +1 -0
- package/dist/delivery/run.d.ts +37 -0
- package/dist/delivery/run.js +234 -0
- package/dist/delivery/run.js.map +1 -0
- package/dist/delivery/webhook.d.ts +41 -0
- package/dist/delivery/webhook.js +120 -0
- package/dist/delivery/webhook.js.map +1 -0
- package/dist/endpoints/public.d.ts +4 -0
- package/dist/endpoints/public.js +27 -0
- package/dist/endpoints/public.js.map +1 -0
- package/dist/endpoints/resend.d.ts +4 -0
- package/dist/endpoints/resend.js +58 -0
- package/dist/endpoints/resend.js.map +1 -0
- package/dist/endpoints/submit.d.ts +4 -0
- package/dist/endpoints/submit.js +320 -0
- package/dist/endpoints/submit.js.map +1 -0
- package/dist/exports/shared.d.ts +13 -0
- package/dist/exports/shared.js +13 -0
- package/dist/exports/shared.js.map +1 -0
- package/dist/formBlock.d.ts +13 -0
- package/dist/formBlock.js +25 -0
- package/dist/formBlock.js.map +1 -0
- package/dist/hooks/resendTick.d.ts +8 -0
- package/dist/hooks/resendTick.js +55 -0
- package/dist/hooks/resendTick.js.map +1 -0
- package/dist/hooks/validateForm.d.ts +8 -0
- package/dist/hooks/validateForm.js +251 -0
- package/dist/hooks/validateForm.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +130 -0
- package/dist/index.js.map +1 -0
- package/dist/security/crypto.d.ts +9 -0
- package/dist/security/crypto.js +54 -0
- package/dist/security/crypto.js.map +1 -0
- package/dist/security/rateLimit.d.ts +13 -0
- package/dist/security/rateLimit.js +28 -0
- package/dist/security/rateLimit.js.map +1 -0
- package/dist/security/request.d.ts +3 -0
- package/dist/security/request.js +13 -0
- package/dist/security/request.js.map +1 -0
- package/dist/security/turnstile.d.ts +28 -0
- package/dist/security/turnstile.js +56 -0
- package/dist/security/turnstile.js.map +1 -0
- package/dist/server/forms.d.ts +73 -0
- package/dist/server/forms.js +215 -0
- package/dist/server/forms.js.map +1 -0
- package/dist/server/resend.d.ts +27 -0
- package/dist/server/resend.js +84 -0
- package/dist/server/resend.js.map +1 -0
- package/dist/shared/answers.d.ts +47 -0
- package/dist/shared/answers.js +282 -0
- package/dist/shared/answers.js.map +1 -0
- package/dist/shared/operators.d.ts +41 -0
- package/dist/shared/operators.js +54 -0
- package/dist/shared/operators.js.map +1 -0
- package/dist/shared/rules.d.ts +58 -0
- package/dist/shared/rules.js +324 -0
- package/dist/shared/rules.js.map +1 -0
- package/dist/shared/steps.d.ts +28 -0
- package/dist/shared/steps.js +109 -0
- package/dist/shared/steps.js.map +1 -0
- package/dist/shared/tokens.d.ts +25 -0
- package/dist/shared/tokens.js +96 -0
- package/dist/shared/tokens.js.map +1 -0
- package/dist/shared/types.d.ts +181 -0
- package/dist/shared/types.js +5 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/shared/whatsapp.d.ts +20 -0
- package/dist/shared/whatsapp.js +26 -0
- package/dist/shared/whatsapp.js.map +1 -0
- package/dist/shared/words.d.ts +9 -0
- package/dist/shared/words.js +41 -0
- package/dist/shared/words.js.map +1 -0
- package/dist/types.d.ts +182 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +114 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kairos Software Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,462 @@
|
|
|
1
|
-
#
|
|
1
|
+
# payload-dynamic-forms
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Forms for [Payload CMS](https://payloadcms.com) 3, installed with one plugin call.
|
|
4
|
+
|
|
5
|
+
**What it does**
|
|
6
|
+
|
|
7
|
+
- Adds three collections: **forms** (sections, field blocks, show/hide rules, steps, every visitor word, delivery settings; drafts and 20 versions), **submissions** (answers, a labelled snapshot, status, delivery log, resend) and **attachments** (private uploads).
|
|
8
|
+
- Checks every answer on the server with the same rule engine your renderer uses in the browser: show/hide rules, required-while-shown, the step path, email, number, date, length, listed choices and files.
|
|
9
|
+
- Spam checks on the server: honeypot, Cloudflare Turnstile and a soft per-visitor rate limit.
|
|
10
|
+
- Delivery: store or not, email through your app's Payload email adapter (`payload.sendEmail`), a WhatsApp link (nothing stored), signed webhooks with retries; optionally after the reply.
|
|
11
|
+
- Choices from a collection (with attributes that rules and message tokens can read) and prefill from link parameters.
|
|
12
|
+
|
|
13
|
+
**What it does not do**
|
|
14
|
+
|
|
15
|
+
- It ships **no visitor UI**: no React components, no styles, no admin components. Each app renders its own form markup with the helpers from `payload-dynamic-forms/shared` (see [Rendering](#rendering)).
|
|
16
|
+
- It does not wrap Payload's official form builder; both can run in one app (their slugs differ).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
The package is on the public npm registry; no token is needed.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install payload-dynamic-forms
|
|
24
|
+
# or
|
|
25
|
+
yarn add payload-dynamic-forms
|
|
26
|
+
# or
|
|
27
|
+
pnpm add payload-dynamic-forms
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then add the plugin to your Payload config:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { buildConfig } from 'payload'
|
|
34
|
+
import { formsPlugin } from 'payload-dynamic-forms'
|
|
35
|
+
|
|
36
|
+
export default buildConfig({
|
|
37
|
+
// ...
|
|
38
|
+
plugins: [
|
|
39
|
+
formsPlugin({
|
|
40
|
+
turnstile: { secretKey: process.env.TURNSTILE_SECRET_KEY },
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
Every option is optional.
|
|
49
|
+
|
|
50
|
+
| Option | Default | What it does |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `enabled` | `true` | `false` returns your config untouched (see [Switching off](#switching-off)). |
|
|
53
|
+
| `slugs` | `custom-forms`, `custom-form-submissions`, `custom-form-attachments` | Renames the collections; relationships and endpoints follow. |
|
|
54
|
+
| `formOverrides`, `submissionOverrides`, `attachmentOverrides` | – | Any collection property; `access`, `admin`, `hooks` and `upload` are merged key by key; `fields: ({ defaultFields }) => [...]`. |
|
|
55
|
+
| `fields` | all built-in types, `fileUpload` off | Field-type registry: `true`/`false` turns a type on or off, a partial block is deep-merged into the built-in one, `{ block, validate?, normalize?, toText? }` adds a new type that the server checks too. |
|
|
56
|
+
| `optionSources` | – | `{ topics: { collection: 'topics', labelField: 'label', valueField: 'slug', attributes: ['opener'] } }`: choices from a collection. |
|
|
57
|
+
| `words` | `DEFAULT_WORDS` | Default visitor words for new forms. |
|
|
58
|
+
| `attachments` | 10 MB per file, 25 MB per submission, PDF, images, text, CSV, Word | `{ collection?, maxFileSize?, maxTotalSize?, mimeTypes? }`, or `false` for no attachments. |
|
|
59
|
+
| `turnstile` | off | `{ enabled?, secretKey?, fieldName? }`. On without a secret refuses every submission and logs one line. The token field defaults to `cf-turnstile-response`. |
|
|
60
|
+
| `honeypotField` | `_hp` | Name of the honeypot field. |
|
|
61
|
+
| `rateLimit` | `{ max: 5, windowSeconds: 600 }` | Per form and visitor; `false` turns it off. |
|
|
62
|
+
| `whatsapp.number` | – | `({ req, form }) => string` for forms without their own number. |
|
|
63
|
+
| `delivery.schedule` | – | Runs email and webhooks after the reply (see [Delivery after the reply](#delivery-after-the-reply)). |
|
|
64
|
+
| `beforeEmail` | – | `(message, { form, answers, req }) => message`: wrap the html in your layout, add headers. |
|
|
65
|
+
| `webhooks` | admin secrets on, signing optional | `{ signingSecret?, headers?, secretsInAdmin?, requireSignature? }`: webhook secrets and headers from your config (values or `({ form, req, webhook }) => …`), whether the admin has secret fields, and whether unsigned webhooks are refused (see [Webhook signature](#webhook-signature)). |
|
|
66
|
+
| `access.isAdmin` | any logged-in user | Who reads and writes webhook secrets in the admin, updates or deletes submissions and resends. Prefer secrets from your config (`webhooks.signingSecret` from an env var, with `webhooks.secretsInAdmin: false`): admin secrets are stored as plain text in your database and its backups. |
|
|
67
|
+
| `metadata` | `{ ip: false, userAgent: true }` | What a submission keeps about the request. |
|
|
68
|
+
|
|
69
|
+
### Field types
|
|
70
|
+
|
|
71
|
+
Built in: heading and help text (display only), text, email, long text, number, date, select, radio, checkbox, checkbox group and file upload. Each input has a key (stored as kebab-case), required (off by default), width, a default value, a link parameter to prefill from, show/hide rules, its own words (label, hint, placeholder, error when empty, error when invalid, row label in messages) and its type's limits.
|
|
72
|
+
|
|
73
|
+
A new type:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
formsPlugin({
|
|
77
|
+
fields: {
|
|
78
|
+
postcode: {
|
|
79
|
+
block: { slug: 'postcode', fields: [] }, // key, required, words and rules are added for you
|
|
80
|
+
validate: (value) => (/\d/.test(String(value)) ? true : 'A postcode has at least one digit.'),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Words
|
|
87
|
+
|
|
88
|
+
Every word a visitor reads is a field: per question (label, hint, placeholder, errors, row label) and per form in **Form wording** (send, sending, sent, continue, back, choose one, step progress, optional hint, error prefix, closed, success, not found, required, invalid, file rejected, too many tries, security check failed, generic error, and the yes and no that a ticked or unticked checkbox becomes in emails, WhatsApp messages, webhooks and the snapshot). Word fields are localized when your app has localization. `getPublicForm` fills empty words from the defaults.
|
|
89
|
+
|
|
90
|
+
### Tokens
|
|
91
|
+
|
|
92
|
+
Email subjects and bodies and WhatsApp templates take `{{key}}` (dashes and underscores allowed), `{{key.attribute}}` (an attribute of the chosen option), `{{formTitle}}`, `{{submissionId}}`, `{{*}}` (one `Label: value` line per answered, shown field) and `{{*:table}}` (the same rows as an HTML table). Unknown tokens become empty; values are HTML-escaped in html and line breaks are removed from subjects. Email addresses (`to`, `cc`, `bcc`, `from`) can't hold tokens: to write to the visitor, add a **visitor copy** naming an email field.
|
|
93
|
+
|
|
94
|
+
## Attachments and storage order
|
|
95
|
+
|
|
96
|
+
Attachments are a normal Payload upload collection with no public read: only logged-in users read them, and only the submit endpoint creates them. To keep the files in R2 (or S3, …), map the collection in your storage plugin and list the storage plugin **after** `formsPlugin`, so the collection exists when the storage plugin runs:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
plugins: [
|
|
100
|
+
formsPlugin({ fields: { fileUpload: true } }),
|
|
101
|
+
r2Storage({ bucket: env.R2, collections: { 'custom-form-attachments': true } }),
|
|
102
|
+
]
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
To use an upload collection of your own instead, pass `attachments: { collection: 'private-files' }`: the plugin adds its link fields (form, submission, field key and label) to it and refuses to start if it is not an upload collection. File uploads need a form that stores submissions.
|
|
106
|
+
|
|
107
|
+
## Migrations
|
|
108
|
+
|
|
109
|
+
The plugin adds collections, so apps on Postgres or SQLite/D1 with migrations run `payload migrate:create` after installing it (and after changing `slugs`, `fields` or `optionSources`), then `payload migrate`. Default slugs differ from the official form builder's `forms` and `form-submissions`, so both can run while you move over.
|
|
110
|
+
|
|
111
|
+
Setting `webhooks.secretsInAdmin: false` also changes the schema: see [Secrets from your config](#secrets-from-your-config).
|
|
112
|
+
|
|
113
|
+
## Switching off
|
|
114
|
+
|
|
115
|
+
`formsPlugin({ enabled: false })` returns your config unchanged: no collections, fields, endpoints or hooks. A starter can carry the call switched off.
|
|
116
|
+
|
|
117
|
+
**Warning:** switching it off on an app that already has forms or submissions drops those tables at the next migration you create. Export what you need first.
|
|
118
|
+
|
|
119
|
+
## Rendering
|
|
120
|
+
|
|
121
|
+
The package has no UI. Read a published form with `getPublicForm(payload, slug, { locale })` on the server, or `GET /api/custom-forms/public/:slug`, and render it with the shared helpers: `initialAnswers` (defaults and link-parameter prefill), `getVisibility` (what is shown, and in step-by-step forms the path), `getStepPath`, `getNextSectionKey`, `getStepProgress`, `checkAnswers` (the same checks the server runs) and the form's words. To place a form on any page, add `formBlock()` to your page blocks; it holds one relationship to a form.
|
|
122
|
+
|
|
123
|
+
A minimal client component (`examples/minimal-form.tsx`, typechecked in this repo):
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
'use client'
|
|
127
|
+
|
|
128
|
+
import {
|
|
129
|
+
type Answers,
|
|
130
|
+
checkAnswers,
|
|
131
|
+
type FieldBlock,
|
|
132
|
+
getFieldKey,
|
|
133
|
+
getOptions,
|
|
134
|
+
getVisibility,
|
|
135
|
+
initialAnswers,
|
|
136
|
+
type InputBlock,
|
|
137
|
+
isInputBlock,
|
|
138
|
+
type PublicForm,
|
|
139
|
+
type SubmitResult,
|
|
140
|
+
} from 'payload-dynamic-forms/shared'
|
|
141
|
+
import { type FormEvent, useState } from 'react'
|
|
142
|
+
|
|
143
|
+
type Props = {
|
|
144
|
+
/** From `getPublicForm(payload, slug)` or `GET /api/custom-forms/public/:slug`. */
|
|
145
|
+
form: PublicForm
|
|
146
|
+
/** The page's search parameters, for prefilled answers (`?need=cameras`). */
|
|
147
|
+
searchParams?: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A minimal single-page renderer: every word comes from the form. */
|
|
151
|
+
export function MinimalForm({ form, searchParams }: Props) {
|
|
152
|
+
const words = form.wording
|
|
153
|
+
const [answers, setAnswers] = useState<Answers>(() => initialAnswers(form, searchParams))
|
|
154
|
+
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
155
|
+
const [message, setMessage] = useState('')
|
|
156
|
+
const [sending, setSending] = useState(false)
|
|
157
|
+
const shown = getVisibility(form, answers)
|
|
158
|
+
|
|
159
|
+
const set = (key: string, value: Answers[string]) => setAnswers((current) => ({ ...current, [key]: value }))
|
|
160
|
+
|
|
161
|
+
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
162
|
+
event.preventDefault()
|
|
163
|
+
// Open the WhatsApp window now, inside the click: after an await, browsers block popups.
|
|
164
|
+
const popup = form.whatsapp ? window.open('', '_blank') : null
|
|
165
|
+
const local = checkAnswers(form, answers, { words })
|
|
166
|
+
if (!local.ok) {
|
|
167
|
+
popup?.close()
|
|
168
|
+
setErrors(local.fieldErrors)
|
|
169
|
+
setMessage(words.errorPrefix)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
setSending(true)
|
|
173
|
+
const response = await fetch(`/api/custom-forms/submit/${form.slug}`, {
|
|
174
|
+
body: JSON.stringify({ ...answers, [form.honeypotField]: '' }),
|
|
175
|
+
headers: { 'Content-Type': 'application/json' },
|
|
176
|
+
method: 'POST',
|
|
177
|
+
})
|
|
178
|
+
const result = (await response.json()) as SubmitResult
|
|
179
|
+
setSending(false)
|
|
180
|
+
if (!result.ok) {
|
|
181
|
+
popup?.close()
|
|
182
|
+
setErrors(result.fieldErrors || {})
|
|
183
|
+
setMessage(result.message)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (result.whatsappUrl && popup) {
|
|
187
|
+
popup.location.href = result.whatsappUrl
|
|
188
|
+
}
|
|
189
|
+
if (result.redirectURL) {
|
|
190
|
+
window.location.assign(result.redirectURL)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
setErrors({})
|
|
194
|
+
setMessage(result.message)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function renderInput(field: InputBlock, key: string) {
|
|
198
|
+
const label = field.words?.label || key
|
|
199
|
+
const value = answers[key]
|
|
200
|
+
const common = { id: key, name: key, required: Boolean(field.required) }
|
|
201
|
+
switch (field.blockType) {
|
|
202
|
+
case 'checkbox':
|
|
203
|
+
return (
|
|
204
|
+
<label htmlFor={key}>
|
|
205
|
+
<input {...common} checked={value === true} onChange={(e) => set(key, e.target.checked)} type="checkbox" />
|
|
206
|
+
{label}
|
|
207
|
+
</label>
|
|
208
|
+
)
|
|
209
|
+
case 'checkboxGroup':
|
|
210
|
+
case 'radio':
|
|
211
|
+
return (
|
|
212
|
+
<fieldset>
|
|
213
|
+
<legend>{label}</legend>
|
|
214
|
+
{getOptions(field).map((option) => {
|
|
215
|
+
const list = Array.isArray(value) ? value : []
|
|
216
|
+
const checked = field.blockType === 'radio' ? value === option.value : list.includes(String(option.value))
|
|
217
|
+
return (
|
|
218
|
+
<label key={option.value}>
|
|
219
|
+
<input
|
|
220
|
+
checked={checked}
|
|
221
|
+
name={key}
|
|
222
|
+
onChange={(e) =>
|
|
223
|
+
field.blockType === 'radio'
|
|
224
|
+
? set(key, String(option.value))
|
|
225
|
+
: set(key, e.target.checked ? [...list, String(option.value)] : list.filter((v) => v !== option.value))
|
|
226
|
+
}
|
|
227
|
+
type={field.blockType === 'radio' ? 'radio' : 'checkbox'}
|
|
228
|
+
value={String(option.value)}
|
|
229
|
+
/>
|
|
230
|
+
{option.label}
|
|
231
|
+
</label>
|
|
232
|
+
)
|
|
233
|
+
})}
|
|
234
|
+
</fieldset>
|
|
235
|
+
)
|
|
236
|
+
case 'select':
|
|
237
|
+
return (
|
|
238
|
+
<label htmlFor={key}>
|
|
239
|
+
{label}
|
|
240
|
+
<select {...common} onChange={(e) => set(key, e.target.value)} value={String(value ?? '')}>
|
|
241
|
+
<option value="">{words.chooseOne}</option>
|
|
242
|
+
{getOptions(field).map((option) => (
|
|
243
|
+
<option key={option.value} value={String(option.value)}>
|
|
244
|
+
{option.label}
|
|
245
|
+
</option>
|
|
246
|
+
))}
|
|
247
|
+
</select>
|
|
248
|
+
</label>
|
|
249
|
+
)
|
|
250
|
+
case 'textarea':
|
|
251
|
+
return (
|
|
252
|
+
<label htmlFor={key}>
|
|
253
|
+
{label}
|
|
254
|
+
<textarea {...common} onChange={(e) => set(key, e.target.value)} placeholder={field.words?.placeholder || undefined} value={String(value ?? '')} />
|
|
255
|
+
</label>
|
|
256
|
+
)
|
|
257
|
+
default:
|
|
258
|
+
return (
|
|
259
|
+
<label htmlFor={key}>
|
|
260
|
+
{label} {field.required ? null : words.optionalHint}
|
|
261
|
+
<input
|
|
262
|
+
{...common}
|
|
263
|
+
onChange={(e) => set(key, e.target.value)}
|
|
264
|
+
placeholder={field.words?.placeholder || undefined}
|
|
265
|
+
type={['date', 'email', 'number'].includes(field.blockType) ? field.blockType : 'text'}
|
|
266
|
+
value={String(value ?? '')}
|
|
267
|
+
/>
|
|
268
|
+
</label>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function renderField(field: FieldBlock, index: number) {
|
|
274
|
+
if (!isInputBlock(field)) {
|
|
275
|
+
return field.blockType === 'heading' ? <h3 key={index}>{field.heading}</h3> : <p key={index}>{field.body}</p>
|
|
276
|
+
}
|
|
277
|
+
const key = getFieldKey(field)
|
|
278
|
+
if (!shown.fieldKeys.has(key)) {
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
return (
|
|
282
|
+
<div key={key}>
|
|
283
|
+
{renderInput(field, key)}
|
|
284
|
+
{field.words?.hint ? <small>{field.words.hint}</small> : null}
|
|
285
|
+
{errors[key] ? <strong role="alert">{errors[key]}</strong> : null}
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (form.intakeState === 'closed') {
|
|
291
|
+
return <p>{words.closed}</p>
|
|
292
|
+
}
|
|
293
|
+
return (
|
|
294
|
+
<form noValidate onSubmit={onSubmit}>
|
|
295
|
+
{form.sections
|
|
296
|
+
.filter((section) => shown.sectionKeys.includes(String(section.key)))
|
|
297
|
+
.map((section) => (
|
|
298
|
+
<section key={String(section.key)}>
|
|
299
|
+
{section.title ? <h2>{section.title}</h2> : null}
|
|
300
|
+
{(section.fields || []).map(renderField)}
|
|
301
|
+
</section>
|
|
302
|
+
))}
|
|
303
|
+
<input aria-hidden autoComplete="off" hidden name={form.honeypotField} tabIndex={-1} />
|
|
304
|
+
{message ? <p role="status">{message}</p> : null}
|
|
305
|
+
<button disabled={sending} type="submit">
|
|
306
|
+
{sending ? words.sending : words.send}
|
|
307
|
+
</button>
|
|
308
|
+
</form>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
For file uploads, post `multipart/form-data` instead of JSON (one entry per file under the field key). For Turnstile, render the widget inside the form; its token goes in the `cf-turnstile-response` field.
|
|
314
|
+
|
|
315
|
+
## WhatsApp
|
|
316
|
+
|
|
317
|
+
A form with WhatsApp delivery builds `https://wa.me/<digits>?text=<message>` from a template (default `{{*}}`) and a row template (default `*{{label}}:* {{value}}`, using each field's row label). The number comes from the form, else from the `whatsapp.number` option. The submit endpoint returns the link as `whatsappUrl`; `buildWhatsAppMessage` and `whatsappUrl` in `/shared` give the same link in the browser.
|
|
318
|
+
|
|
319
|
+
**Popup note:** browsers block `window.open` after an `await`. Open the window inside the click handler before posting (as the example does), then set its location when the link arrives, or close it on an error.
|
|
320
|
+
|
|
321
|
+
A form that stores nothing and only builds a WhatsApp link skips the honeypot, Turnstile and rate limit: nothing is written or sent by the server.
|
|
322
|
+
|
|
323
|
+
## Delivery after the reply
|
|
324
|
+
|
|
325
|
+
By default email and webhooks run before the endpoint answers. To answer first, pass a scheduler:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import { after } from 'next/server'
|
|
329
|
+
|
|
330
|
+
formsPlugin({ delivery: { schedule: (task) => after(task) } })
|
|
331
|
+
// or on Cloudflare Workers: schedule: (task) => getCloudflareContext().ctx.waitUntil(task())
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
The submission is always stored before the reply (status `received`); the task sends and sets the status. Workers keep `waitUntil` work for up to 30 s after the response: keep webhook timeouts short there (8 s with 3 retries can pass 30 s).
|
|
335
|
+
|
|
336
|
+
## Endpoints
|
|
337
|
+
|
|
338
|
+
| Endpoint | Who | Answer |
|
|
339
|
+
| --- | --- | --- |
|
|
340
|
+
| `GET /api/<forms>/public/:slug` | anyone | The published form, ready to render, or 404 `{ ok: false, message }`. |
|
|
341
|
+
| `POST /api/<forms>/submit/:slug` | anyone | JSON or `multipart/form-data` keyed by field key, plus the honeypot and Turnstile fields. |
|
|
342
|
+
| `POST /api/<submissions>/:id/resend` | admins | Runs failed email and webhook deliveries again: `{ ok, status, resent, deliveryLog }`; 403 for others. |
|
|
343
|
+
|
|
344
|
+
Submit answers:
|
|
345
|
+
|
|
346
|
+
- `200 { ok: true, message, redirectURL?, submissionId?, whatsappUrl? }`
|
|
347
|
+
- `400 { ok: false, message, fieldErrors? }`: field errors keyed by field key, honeypot, Turnstile.
|
|
348
|
+
- `404` unknown or unpublished form, `409` closed form, `429` too many tries, `500` storing failed or no WhatsApp number.
|
|
349
|
+
|
|
350
|
+
Every message comes from the form's words. Logged-in admins can also resend by ticking **Resend failed deliveries** on a submission and saving.
|
|
351
|
+
|
|
352
|
+
### Delivery log and status
|
|
353
|
+
|
|
354
|
+
Each email or webhook try of a stored submission adds a log entry (kind, output, target, result, error, tries, time). The status is `processed` when all succeeded (or there was nothing to send), `partlyFailed` when some failed and `failed` when all failed. A failed email or webhook never loses the submission.
|
|
355
|
+
|
|
356
|
+
## Webhook signature
|
|
357
|
+
|
|
358
|
+
Webhooks send JSON (full: form, submission id, snapshot, attachments and answers; or answers only) by POST, PUT or PATCH with their headers, a timeout (default 8 s) and up to 3 retries (pauses 0.5 s, 1 s, 2 s).
|
|
359
|
+
|
|
360
|
+
### Secrets from your config
|
|
361
|
+
|
|
362
|
+
A webhook's signing secret and secret headers can come from two places: the admin-only fields on each webhook row, or the plugin config. Keep them in env vars when you can:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
formsPlugin({
|
|
366
|
+
webhooks: {
|
|
367
|
+
// A string, or a function of { form, req, webhook: { id, label, url } } (may be async).
|
|
368
|
+
signingSecret: ({ webhook }) => (webhook.label === 'CRM' ? process.env.CRM_WEBHOOK_SECRET : process.env.FORMS_WEBHOOK_SECRET),
|
|
369
|
+
headers: { 'X-Api-Key': process.env.CRM_API_KEY! },
|
|
370
|
+
secretsInAdmin: false,
|
|
371
|
+
requireSignature: true,
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
- **Which secret wins:** the webhook's own admin `signingSecret`, when it is not empty, wins over `webhooks.signingSecret`; the config secret is the fallback. An empty string or `undefined` from either means no secret. So an old admin secret left on a form keeps signing even after you set a config one: clear it, or turn the fields off.
|
|
377
|
+
- **Header order:** `Content-Type: application/json`, then `webhooks.headers`, then the webhook's admin secret headers, then the signature headers. A later header replaces an earlier one of the same name (any case), so admin headers override config ones and nothing overrides `X-Form-Signature` or the `webhook-*` headers.
|
|
378
|
+
- **`secretsInAdmin: false`** leaves the signing secret and secret header fields out of the forms collection (not hidden: gone), so no secret is stored in your database. This changes the schema: your next migration drops the signing-secret column and the webhook headers table, and **any secrets stored there are lost**. Move them to your config first.
|
|
379
|
+
|
|
380
|
+
### Checking `X-Form-Signature`
|
|
381
|
+
|
|
382
|
+
A signed webhook carries `X-Form-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`, keyed by the whole secret string, as in 0.1.0. Check it on the receiving side:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
386
|
+
|
|
387
|
+
export function verifyFormSignature(header: string, rawBody: string, secret: string, toleranceSeconds = 300) {
|
|
388
|
+
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=') as [string, string]))
|
|
389
|
+
const t = Number(parts.t)
|
|
390
|
+
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false
|
|
391
|
+
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
|
|
392
|
+
return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Standard Webhooks
|
|
397
|
+
|
|
398
|
+
Webhooks also carry the [Standard Webhooks](https://www.standardwebhooks.com/) headers next to `X-Form-Signature`, so receivers can use a ready-made verifier:
|
|
399
|
+
|
|
400
|
+
- `webhook-id` on every webhook, signed or not: `msg_<submissionId>_<outputId>` for a stored submission, else `msg_` plus a random id. It is the same on every retry and on every resend of a stored submission, so use it as an idempotency key to skip a delivery you already handled.
|
|
401
|
+
- `webhook-timestamp` (unix seconds, the same `t` as in `X-Form-Signature`) and `webhook-signature: v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<raw body>">` on signed webhooks.
|
|
402
|
+
|
|
403
|
+
The HMAC key is the decoded bytes of a `whsec_` secret, or the secret's UTF-8 bytes otherwise. To verify with the [`standardwebhooks`](https://www.npmjs.com/package/standardwebhooks) library, use a `whsec_` secret (`'whsec_' + base64 of 32 random bytes`):
|
|
404
|
+
|
|
405
|
+
```sh
|
|
406
|
+
node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))"
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
import { Webhook } from 'standardwebhooks'
|
|
411
|
+
|
|
412
|
+
// Throws when the signature or timestamp doesn't check out; returns the parsed body.
|
|
413
|
+
const payload = new Webhook(process.env.FORMS_WEBHOOK_SECRET!).verify(rawBody, {
|
|
414
|
+
'webhook-id': request.headers.get('webhook-id')!,
|
|
415
|
+
'webhook-timestamp': request.headers.get('webhook-timestamp')!,
|
|
416
|
+
'webhook-signature': request.headers.get('webhook-signature')!,
|
|
417
|
+
})
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
### Unsigned webhooks
|
|
421
|
+
|
|
422
|
+
A webhook with no secret from either place is sent unsigned, and the server logs one warning per webhook per process: `payload-dynamic-forms: webhook "<label>" on form "<slug>" is sent unsigned; set a signing secret.` With `webhooks.requireSignature: true` it isn't sent at all: its delivery log entry is `failed` with 0 tries and the error `Webhook not sent: no signing secret.`, and the status and resend follow the usual rules.
|
|
423
|
+
|
|
424
|
+
New in 0.2.0: receivers see the `webhook-*` headers (and `webhook-id` on unsigned webhooks too). A receiver that refuses unknown headers needs to allow them.
|
|
425
|
+
|
|
426
|
+
## CSV export
|
|
427
|
+
|
|
428
|
+
Use Payload's import-export plugin for the submissions collection (no jobs runner needed in sync mode):
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
importExportPlugin({ collections: ['custom-form-submissions'], disableJobsQueue: true })
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## Privacy and the rate limit
|
|
435
|
+
|
|
436
|
+
Visitor IPs are not stored unless `metadata: { ip: true }`. The rate limit keeps one entry per visitor and form in Payload's KV store (`payload.kv`), keyed by a SHA-256 hash of the IP and your Payload secret, never the raw IP. It is a soft limit (read, then write); Turnstile is the main spam guard.
|
|
437
|
+
|
|
438
|
+
## Compatibility
|
|
439
|
+
|
|
440
|
+
- Payload `^3.92.1` (developed and tested against 3.92.1). Payload 4 is not supported yet.
|
|
441
|
+
- The runtime code uses Web APIs only (Web Crypto, `fetch`, `node:buffer` for uploads), so it runs on Node and on Cloudflare Workers; the `/shared` entry imports nothing from Payload, React or Node and runs in the browser.
|
|
442
|
+
- Tested on SQLite (`@payloadcms/db-sqlite`). Postgres and Workers/D1 are not tested here; nested selects carry short `dbName`s for Postgres.
|
|
443
|
+
|
|
444
|
+
## Development
|
|
445
|
+
|
|
446
|
+
Scaffolded from Payload's plugin template: `create-payload-app` 3.93.0 `--template plugin`, which downloaded `templates/plugin` from the `3.x` branch at commit `6eb8e57198678f2e669b906097f670ec98b17523` (7 Nov 2026). Converted to Yarn 4 and SQLite; the sample code, admin components and Playwright tests were removed.
|
|
447
|
+
|
|
448
|
+
```sh
|
|
449
|
+
corepack enable
|
|
450
|
+
yarn install
|
|
451
|
+
yarn lint && yarn typecheck && yarn test && yarn build
|
|
452
|
+
yarn spec:validate # OpenSpec plans in openspec/
|
|
453
|
+
yarn dev # the dev app in dev/ (copy dev/.env.example to dev/.env)
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
`yarn test` runs the unit tests and the integration tests, which boot the dev app (`dev/payload.config.ts`) on a temporary SQLite file with a recording email adapter. Outbound requests are refused in tests.
|
|
457
|
+
|
|
458
|
+
**Releasing.** Bump `version` in `package.json` on `main` (straight or through a pull request). `.github/workflows/release.yml` then runs every check, publishes the new version to npm with provenance and tags it `v<version>`; a version whose tag already exists is skipped, so other pushes to `main` publish nothing. The first publish needs the `NPM_TOKEN` repository secret, because npm can only trust a package that already exists; after it, add this repo's `release.yml` as the trusted publisher on npmjs.com and delete the secret, and later releases use trusted publishing.
|
|
459
|
+
|
|
460
|
+
## Licence
|
|
461
|
+
|
|
462
|
+
MIT, see [LICENSE](LICENSE).
|
package/dist/access.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Access, FieldAccess, PayloadRequest } from 'payload';
|
|
2
|
+
export declare const loggedIn: ({ req }: {
|
|
3
|
+
req: PayloadRequest;
|
|
4
|
+
}) => boolean;
|
|
5
|
+
export declare const nobody: Access;
|
|
6
|
+
export declare function adminAccess(isAdmin: (req: PayloadRequest) => boolean): Access;
|
|
7
|
+
export declare function adminFieldAccess(isAdmin: (req: PayloadRequest) => boolean): FieldAccess;
|
package/dist/access.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const loggedIn = ({ req })=>Boolean(req.user);
|
|
2
|
+
export const nobody = ()=>false;
|
|
3
|
+
export function adminAccess(isAdmin) {
|
|
4
|
+
return ({ req })=>Boolean(req.user) && isAdmin(req);
|
|
5
|
+
}
|
|
6
|
+
export function adminFieldAccess(isAdmin) {
|
|
7
|
+
return ({ req })=>Boolean(req.user) && isAdmin(req);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
//# sourceMappingURL=access.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/access.ts"],"sourcesContent":["import type { Access, FieldAccess, PayloadRequest } from 'payload'\n\nexport const loggedIn = ({ req }: { req: PayloadRequest }): boolean => Boolean(req.user)\n\nexport const nobody: Access = () => false\n\nexport function adminAccess(isAdmin: (req: PayloadRequest) => boolean): Access {\n return ({ req }) => Boolean(req.user) && isAdmin(req)\n}\n\nexport function adminFieldAccess(isAdmin: (req: PayloadRequest) => boolean): FieldAccess {\n return ({ req }) => Boolean(req.user) && isAdmin(req)\n}\n"],"names":["loggedIn","req","Boolean","user","nobody","adminAccess","isAdmin","adminFieldAccess"],"mappings":"AAEA,OAAO,MAAMA,WAAW,CAAC,EAAEC,GAAG,EAA2B,GAAcC,QAAQD,IAAIE,IAAI,EAAC;AAExF,OAAO,MAAMC,SAAiB,IAAM,MAAK;AAEzC,OAAO,SAASC,YAAYC,OAAyC;IACnE,OAAO,CAAC,EAAEL,GAAG,EAAE,GAAKC,QAAQD,IAAIE,IAAI,KAAKG,QAAQL;AACnD;AAEA,OAAO,SAASM,iBAAiBD,OAAyC;IACxE,OAAO,CAAC,EAAEL,GAAG,EAAE,GAAKC,QAAQD,IAAIE,IAAI,KAAKG,QAAQL;AACnD"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CollectionConfig, Field } from 'payload';
|
|
2
|
+
import type { ResolvedOptions } from '../types.js';
|
|
3
|
+
/** The fields that link a file to its form, submission and question. */
|
|
4
|
+
export declare function attachmentLinkFields(options: ResolvedOptions): Field[];
|
|
5
|
+
/** Private uploads: created only by the submit endpoint, read by logged-in users. */
|
|
6
|
+
export declare function attachmentsCollection(options: ResolvedOptions): CollectionConfig;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { adminAccess, loggedIn, nobody } from '../access.js';
|
|
2
|
+
/** The fields that link a file to its form, submission and question. */ export function attachmentLinkFields(options) {
|
|
3
|
+
return [
|
|
4
|
+
{
|
|
5
|
+
name: 'form',
|
|
6
|
+
type: 'relationship',
|
|
7
|
+
admin: {
|
|
8
|
+
readOnly: true
|
|
9
|
+
},
|
|
10
|
+
relationTo: options.slugs.forms
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'submission',
|
|
14
|
+
type: 'relationship',
|
|
15
|
+
admin: {
|
|
16
|
+
readOnly: true
|
|
17
|
+
},
|
|
18
|
+
relationTo: options.slugs.submissions
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'fieldKey',
|
|
22
|
+
type: 'text',
|
|
23
|
+
admin: {
|
|
24
|
+
readOnly: true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'fieldLabel',
|
|
29
|
+
type: 'text',
|
|
30
|
+
admin: {
|
|
31
|
+
readOnly: true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
/** Private uploads: created only by the submit endpoint, read by logged-in users. */ export function attachmentsCollection(options) {
|
|
37
|
+
const admin = adminAccess(options.isAdmin);
|
|
38
|
+
return {
|
|
39
|
+
slug: options.attachments.collection,
|
|
40
|
+
access: {
|
|
41
|
+
create: nobody,
|
|
42
|
+
delete: admin,
|
|
43
|
+
read: loggedIn,
|
|
44
|
+
update: admin
|
|
45
|
+
},
|
|
46
|
+
admin: {
|
|
47
|
+
defaultColumns: [
|
|
48
|
+
'filename',
|
|
49
|
+
'fieldLabel',
|
|
50
|
+
'form',
|
|
51
|
+
'createdAt'
|
|
52
|
+
],
|
|
53
|
+
group: 'Forms',
|
|
54
|
+
useAsTitle: 'filename'
|
|
55
|
+
},
|
|
56
|
+
fields: attachmentLinkFields(options),
|
|
57
|
+
upload: {
|
|
58
|
+
crop: false,
|
|
59
|
+
focalPoint: false,
|
|
60
|
+
mimeTypes: [
|
|
61
|
+
...options.attachments.mimeTypes
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
//# sourceMappingURL=attachments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/collections/attachments.ts"],"sourcesContent":["import type { CollectionConfig, Field } from 'payload'\n\nimport type { ResolvedOptions } from '../types.js'\n\nimport { adminAccess, loggedIn, nobody } from '../access.js'\n\n/** The fields that link a file to its form, submission and question. */\nexport function attachmentLinkFields(options: ResolvedOptions): Field[] {\n return [\n { name: 'form', type: 'relationship', admin: { readOnly: true }, relationTo: options.slugs.forms as never },\n { name: 'submission', type: 'relationship', admin: { readOnly: true }, relationTo: options.slugs.submissions as never },\n { name: 'fieldKey', type: 'text', admin: { readOnly: true } },\n { name: 'fieldLabel', type: 'text', admin: { readOnly: true } },\n ]\n}\n\n/** Private uploads: created only by the submit endpoint, read by logged-in users. */\nexport function attachmentsCollection(options: ResolvedOptions): CollectionConfig {\n const admin = adminAccess(options.isAdmin)\n return {\n slug: options.attachments!.collection,\n access: {\n create: nobody,\n delete: admin,\n read: loggedIn,\n update: admin,\n },\n admin: {\n defaultColumns: ['filename', 'fieldLabel', 'form', 'createdAt'],\n group: 'Forms',\n useAsTitle: 'filename',\n },\n fields: attachmentLinkFields(options),\n upload: {\n crop: false,\n focalPoint: false,\n mimeTypes: [...options.attachments!.mimeTypes],\n },\n }\n}\n"],"names":["adminAccess","loggedIn","nobody","attachmentLinkFields","options","name","type","admin","readOnly","relationTo","slugs","forms","submissions","attachmentsCollection","isAdmin","slug","attachments","collection","access","create","delete","read","update","defaultColumns","group","useAsTitle","fields","upload","crop","focalPoint","mimeTypes"],"mappings":"AAIA,SAASA,WAAW,EAAEC,QAAQ,EAAEC,MAAM,QAAQ,eAAc;AAE5D,sEAAsE,GACtE,OAAO,SAASC,qBAAqBC,OAAwB;IAC3D,OAAO;QACL;YAAEC,MAAM;YAAQC,MAAM;YAAgBC,OAAO;gBAAEC,UAAU;YAAK;YAAGC,YAAYL,QAAQM,KAAK,CAACC,KAAK;QAAU;QAC1G;YAAEN,MAAM;YAAcC,MAAM;YAAgBC,OAAO;gBAAEC,UAAU;YAAK;YAAGC,YAAYL,QAAQM,KAAK,CAACE,WAAW;QAAU;QACtH;YAAEP,MAAM;YAAYC,MAAM;YAAQC,OAAO;gBAAEC,UAAU;YAAK;QAAE;QAC5D;YAAEH,MAAM;YAAcC,MAAM;YAAQC,OAAO;gBAAEC,UAAU;YAAK;QAAE;KAC/D;AACH;AAEA,mFAAmF,GACnF,OAAO,SAASK,sBAAsBT,OAAwB;IAC5D,MAAMG,QAAQP,YAAYI,QAAQU,OAAO;IACzC,OAAO;QACLC,MAAMX,QAAQY,WAAW,CAAEC,UAAU;QACrCC,QAAQ;YACNC,QAAQjB;YACRkB,QAAQb;YACRc,MAAMpB;YACNqB,QAAQf;QACV;QACAA,OAAO;YACLgB,gBAAgB;gBAAC;gBAAY;gBAAc;gBAAQ;aAAY;YAC/DC,OAAO;YACPC,YAAY;QACd;QACAC,QAAQvB,qBAAqBC;QAC7BuB,QAAQ;YACNC,MAAM;YACNC,YAAY;YACZC,WAAW;mBAAI1B,QAAQY,WAAW,CAAEc,SAAS;aAAC;QAChD;IACF;AACF"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Block, Field } from 'payload';
|
|
2
|
+
import type { FieldTypeChecks } from '../shared/types.js';
|
|
3
|
+
import type { FormsPluginOptions, OptionSource } from '../types.js';
|
|
4
|
+
/** Every word one question shows, grouped and localized. */
|
|
5
|
+
export declare function fieldWordsField(): Field;
|
|
6
|
+
/** Fields every input block has: key, width, required, words, default, link parameter, rules. */
|
|
7
|
+
export declare function baseInputFields(defaultValue?: Field): Field[];
|
|
8
|
+
export type BlockContext = {
|
|
9
|
+
mimeTypes: string[];
|
|
10
|
+
sources: Record<string, OptionSource>;
|
|
11
|
+
};
|
|
12
|
+
/** The built-in field blocks, in their admin order. */
|
|
13
|
+
export declare function builtInBlocks(ctx: BlockContext): Record<string, Block>;
|
|
14
|
+
export declare const BUILT_IN_TYPES: readonly ["heading", "helpText", "text", "email", "textarea", "number", "date", "select", "radio", "checkbox", "checkboxGroup", "fileUpload"];
|
|
15
|
+
/** Deep-merges plain objects; arrays and other values from `patch` replace. */
|
|
16
|
+
export declare function deepMerge<T>(base: T, patch: unknown): T;
|
|
17
|
+
/**
|
|
18
|
+
* Applies the `fields` registry: built-ins on/off (fileUpload off by default), partial blocks
|
|
19
|
+
* merged, new types added with the common input fields. Returns the blocks and the checks for
|
|
20
|
+
* app-defined types.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildFieldBlocks(registry: FormsPluginOptions['fields'], ctx: {
|
|
23
|
+
attachmentsOn: boolean;
|
|
24
|
+
} & BlockContext): {
|
|
25
|
+
blocks: Block[];
|
|
26
|
+
fieldTypes: Record<string, FieldTypeChecks>;
|
|
27
|
+
};
|