Skip to content

Commit aa01a45

Browse files
authored
feat(plugin-form-builder): add support for multi part uploads (#15268)
Adds support for multi part uploads directly in the plugin, this makes it so you don't need to upload documents separately and instead the form submissions themselves can handle this part. Before ```ts // Step 1: upload the file const uploadForm = new FormData() uploadForm.append('file', imageFile) const { doc: media } = await fetch('/api/media', { method: 'POST', body: uploadForm, }).then(r => r.json()) // Step 2: submit the form with the pre-uploaded ID await fetch('/api/form-submissions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ form: formId, submissionData: [ { field: 'fullName', value: 'Alice' }, { field: 'avatar', value: media.id }, // string ID only ], }), }) ``` After ```ts const formData = new FormData() formData.append('_payload', JSON.stringify({ form: formId, submissionData: [{ field: 'fullName', value: 'Alice' }], })) formData.append('avatar', imageFile) // keyed by field name await fetch('/api/form-submissions', { method: 'POST', body: formData, }) ```
1 parent 7f0b069 commit aa01a45

22 files changed

Lines changed: 3386 additions & 34 deletions

File tree

docs/plugins/form-builder.mdx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ formBuilderPlugin({
8888
message: true,
8989
date: false,
9090
payment: false,
91+
upload: {
92+
uploadCollections: ['media', 'documents'], // Required when using upload
93+
},
9194
},
9295
})
9396
```
@@ -441,6 +444,180 @@ Each of the `priceConditions` are executed by the `getPaymentTotal` utility that
441444
| `valueType` | string | The type of value to use to determine the price. |
442445
| `value` | string | The value to use to determine the price. |
443446

447+
### Upload
448+
449+
Add this field to your form to collect file uploads. Files are stored in a specified upload-enabled collection, and the file ID is stored as the submission value. This field is disabled by default and must be explicitly enabled.
450+
451+
| Property | Type | Description |
452+
| ------------------ | --------- | -------------------------------------------------------------------------------------------- |
453+
| `name` | string | The name of the field. |
454+
| `label` | string | The label of the field. |
455+
| `uploadCollection` | string \* | The slug of the upload-enabled collection to store files in. |
456+
| `mimeTypes` | array | An array of allowed MIME types (e.g., `image/*`, `application/pdf`). Empty allows all types. |
457+
| `maxFileSize` | number | Maximum file size in bytes. Leave empty for no limit. |
458+
| `width` | number | The width of the field as a percentage (e.g., `50` for 50%). |
459+
| `required` | checkbox | Whether or not the field is required when submitted. |
460+
| `multiple` | checkbox | Whether to allow multiple file uploads. |
461+
462+
#### Enabling Upload Fields
463+
464+
To use upload fields, enable the `upload` field and provide `uploadCollections` at the top level of the plugin config:
465+
466+
```ts
467+
// payload.config.ts
468+
formBuilderPlugin({
469+
fields: {
470+
upload: true,
471+
},
472+
uploadCollections: ['media', 'documents'], // Required — available upload collections
473+
})
474+
```
475+
476+
#### Frontend Implementation
477+
478+
The simplest way to handle upload fields is to submit files directly with the form as `multipart/form-data`. The form builder will automatically upload files to the appropriate collection and store the file IDs in the submission.
479+
480+
```ts
481+
async function submitFormWithFiles(
482+
formId: string,
483+
fields: FormField[],
484+
values: Record<string, any>,
485+
) {
486+
const formData = new FormData()
487+
488+
// Build submission data for non-file fields
489+
const submissionData = []
490+
491+
for (const field of fields) {
492+
if (field.blockType === 'upload') {
493+
// Files are appended to FormData separately using the field name
494+
if (values[field.name]) {
495+
formData.append(field.name, values[field.name])
496+
}
497+
} else if (values[field.name] !== undefined) {
498+
submissionData.push({ field: field.name, value: values[field.name] })
499+
}
500+
}
501+
502+
// Add the form ID and non-file submission data as JSON
503+
formData.append(
504+
'_payload',
505+
JSON.stringify({
506+
form: formId,
507+
submissionData,
508+
}),
509+
)
510+
511+
const response = await fetch('/api/form-submissions', {
512+
method: 'POST',
513+
body: formData,
514+
})
515+
516+
return response.json()
517+
}
518+
```
519+
520+
The server will:
521+
522+
1. Validate MIME types and file sizes against the field configuration
523+
2. Upload files to the specified upload collection
524+
3. Store the created file IDs in the submission data
525+
526+
#### Alternative: Pre-upload Files
527+
528+
If you prefer more control over the upload process (e.g., for progress tracking or resumable uploads), you can upload files separately and submit their IDs:
529+
530+
```ts
531+
// 1. Upload file to the upload collection
532+
async function uploadFile(file: File, collectionSlug: string) {
533+
const formData = new FormData()
534+
formData.append('file', file)
535+
536+
const response = await fetch(`/api/${collectionSlug}`, {
537+
method: 'POST',
538+
body: formData,
539+
})
540+
541+
return response.json() // { doc: { id, filename, ... } }
542+
}
543+
544+
// 2. Submit form with file ID
545+
async function submitFormWithFileIds(
546+
formId: string,
547+
fields: FormField[],
548+
values: Record<string, any>,
549+
) {
550+
const submissionData = []
551+
552+
for (const field of fields) {
553+
if (field.blockType === 'upload' && values[field.name]) {
554+
// Upload file first
555+
const uploadResult = await uploadFile(
556+
values[field.name],
557+
field.uploadCollection,
558+
)
559+
submissionData.push({ field: field.name, value: uploadResult.doc.id })
560+
} else {
561+
submissionData.push({ field: field.name, value: values[field.name] })
562+
}
563+
}
564+
565+
await fetch('/api/form-submissions', {
566+
method: 'POST',
567+
headers: { 'Content-Type': 'application/json' },
568+
body: JSON.stringify({ form: formId, submissionData }),
569+
})
570+
}
571+
```
572+
573+
#### Using Presigned URLs for Large Files
574+
575+
If your upload collection uses a storage adapter with `clientUploads` enabled (e.g., S3, Azure, GCS), you can upload files directly to cloud storage using presigned URLs for better performance with large files:
576+
577+
```ts
578+
async function uploadWithPresignedUrl(
579+
file: File,
580+
collectionSlug: string,
581+
serverHandlerPath: string = '/storage-s3-generate-signed-url',
582+
) {
583+
// 1. Get presigned URL from Payload
584+
const signedUrlResponse = await fetch(`/api${serverHandlerPath}`, {
585+
method: 'POST',
586+
headers: { 'Content-Type': 'application/json' },
587+
credentials: 'include',
588+
body: JSON.stringify({
589+
collectionSlug,
590+
filename: file.name,
591+
filesize: file.size,
592+
mimeType: file.type,
593+
}),
594+
})
595+
const { url } = await signedUrlResponse.json()
596+
597+
// 2. Upload directly to cloud storage
598+
// Note: the browser sets Content-Length automatically and forbids setting it manually.
599+
await fetch(url, {
600+
method: 'PUT',
601+
body: file,
602+
headers: {
603+
'Content-Type': file.type,
604+
},
605+
})
606+
607+
// 3. Create document in Payload (file already in storage)
608+
const docResponse = await fetch(`/api/${collectionSlug}`, {
609+
method: 'POST',
610+
headers: { 'Content-Type': 'application/json' },
611+
body: JSON.stringify({
612+
filename: file.name,
613+
mimeType: file.type,
614+
filesize: file.size,
615+
}),
616+
})
617+
return docResponse.json()
618+
}
619+
```
620+
444621
### Field Overrides
445622

446623
You can provide your own custom fields by passing a new [Payload Block](https://payloadcms.com/docs/fields/blocks#block-configs) object into `fields`. You can override or extend any existing fields by first importing the `fields` from the plugin:
@@ -556,6 +733,8 @@ import type {
556733
FieldsConfig,
557734
BeforeEmail,
558735
HandlePayment,
736+
UploadField,
737+
UploadFieldMimeType,
559738
...
560739
} from "@payloadcms/plugin-form-builder/types";
561740
```

packages/payload/src/types/index.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
TypedLocale,
2323
TypedUser,
2424
} from '../index.js'
25+
import type { File } from '../uploads/types.js'
2526
import type { Operator } from './constants.js'
2627
export type { Payload } from '../index.js'
2728

@@ -111,12 +112,9 @@ type PayloadRequestData = {
111112
* Context of the file when it was uploaded via client side.
112113
*/
113114
clientUploadContext?: unknown
114-
data: Buffer
115-
mimetype: string
116-
name: string
117-
size: number
118-
tempFilePath?: string
119-
}
115+
} & File
116+
/** All files from multipart form data, keyed by field name */
117+
files?: Record<string, File | File[]>
120118
}
121119
export interface PayloadRequest
122120
extends CustomPayloadRequestProperties,

packages/payload/src/utilities/addDataAndFileToRequest.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,14 @@ export const addDataAndFileToRequest: AddDataAndFileToRequest = async (req) => {
4343
throw new APIError(error.message)
4444
}
4545

46-
if (files?.file) {
47-
req.file = files.file
46+
// Set all files on req.files for access by hooks
47+
if (files) {
48+
req.files = files
49+
// Backwards compatibility: set req.file for standard upload collections
50+
// Guard: if multiple files share the field name "file", files.file is an array — skip
51+
if (files.file && !Array.isArray(files.file)) {
52+
req.file = files.file
53+
}
4854
}
4955

5056
if (fields?._payload && typeof fields._payload === 'string') {

0 commit comments

Comments
 (0)