Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
bc4c31f
Add Upload fieldtype to handle form file uploads
duncanmcclean Jul 14, 2026
cf33d12
refine
duncanmcclean Jul 14, 2026
fb0e539
Merge branch 'forms-2' into forms-2-file-uploads
duncanmcclean Jul 15, 2026
7e07a65
Merge branch 'forms-2' into forms-2-file-uploads
duncanmcclean Jul 15, 2026
358e657
Upload files to separate `form-uploads` directory & only create asset…
duncanmcclean Jul 15, 2026
10441f3
make phpstan happy
duncanmcclean Jul 15, 2026
ee0f6e3
Avoid validation errors when submitting a page with existing file upl…
duncanmcclean Jul 15, 2026
729f785
always display basename in fieldtype. not the full path
duncanmcclean Jul 15, 2026
47a9c7c
simplify file upload validation logic
duncanmcclean Jul 15, 2026
5a39ce6
add test to cover min_files validation when a file is removed without…
duncanmcclean Jul 15, 2026
1a1339a
ensure disk path is correct when adding attachments
duncanmcclean Jul 15, 2026
f3c0c02
prevent path traversal
duncanmcclean Jul 15, 2026
9dfb7ea
tweak field instructions
duncanmcclean Jul 15, 2026
90a66f5
prevent duplicate filenames
duncanmcclean Jul 15, 2026
4be76d1
always clean up temporary upload storage
duncanmcclean Jul 15, 2026
b4aa07a
allow removing files from a multi-file upload when resubmitting a page
duncanmcclean Jul 15, 2026
5b83e81
tweak field instructions (again)
duncanmcclean Jul 15, 2026
d2940aa
Merge branch 'forms-2' into forms-2-file-uploads
duncanmcclean Jul 16, 2026
811eaec
use configured file uploads disk
duncanmcclean Jul 16, 2026
0245c4b
avoid pre-cognitively validating files and form upload fields
duncanmcclean Jul 16, 2026
3522f6e
Merge branch 'forms-2' into forms-2-file-uploads
duncanmcclean Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions config/forms.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@

'forms' => resource_path('forms'),

/*
|--------------------------------------------------------------------------
| File Uploads Path
|--------------------------------------------------------------------------
|
| The path (on the file uploads disk) where file uploads are stored
| before they're converted to an asset or deleted.
|
*/

'file_uploads_path' => env('STATAMIC_FORM_UPLOADS_PATH', 'statamic/form-uploads'),

/*
|--------------------------------------------------------------------------
| Email View Folder
Expand Down Expand Up @@ -41,15 +53,12 @@
|--------------------------------------------------------------------------
|
| Partial submissions are automatically deleted after a set number of days.
| Set this to null to prevent their automatic deletion. You may also enable
| garbage collection to delete related assets at the same time.
| Set this to null to prevent their automatic deletion.
|
*/

'delete_partial_submissions_after' => 7,

'garbage_collect_assets' => false,

/*
|--------------------------------------------------------------------------
| Exporters
Expand Down
2 changes: 2 additions & 0 deletions resources/js/bootstrap/fieldtypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import GroupFieldtype from '../components/fieldtypes/GroupFieldtype.vue';
import FormBannerFieldtype from '../components/fieldtypes/FormBannerFieldtype.vue';
import FormHeadingFieldtype from '../components/fieldtypes/FormHeadingFieldtype.vue';
import FormParagraphFieldtype from '@/components/fieldtypes/FormParagraphFieldtype.vue';
import FormUploadFieldtype from '@/components/fieldtypes/FormUploadFieldtype.vue';
import HiddenFieldtype from '../components/fieldtypes/HiddenFieldtype.vue';
import HtmlFieldtype from '../components/fieldtypes/HtmlFieldtype.vue';
import IconFieldtype from '../components/fieldtypes/IconFieldtype.vue';
Expand Down Expand Up @@ -116,6 +117,7 @@ export default function registerFieldtypes(app) {
app.component('form_banner-fieldtype', FormBannerFieldtype);
app.component('form_heading-fieldtype', FormHeadingFieldtype);
app.component('form_paragraph-fieldtype', FormParagraphFieldtype);
app.component('form_upload-fieldtype', FormUploadFieldtype);
app.component('hidden-fieldtype', HiddenFieldtype);
app.component('html-fieldtype', HtmlFieldtype);
app.component('icon-fieldtype', IconFieldtype);
Expand Down
201 changes: 201 additions & 0 deletions resources/js/components/fieldtypes/FormUploadFieldtype.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
<template>
<div class="@container relative">
<div
v-if="canUpload"
@dragenter="dragenter"
@dragover="dragover"
@dragleave="dragleave"
@drop="drop"
>
<!-- While dragging, children can't be hit-tested, so every drag event during this
drag session lands on this wrapper — never bubbling up from a child with a
mismatched target/currentTarget, which would otherwise leave `dragging` stuck. -->
<div :class="{ 'pointer-events-none': dragging || isReadOnly, 'opacity-60': isReadOnly }">
<div
v-show="dragging"
class="absolute inset-0 z-(--z-index-above) flex gap-2 items-center justify-center bg-white/80 border border-gray-400 border-dashed rounded-lg"
>
<ui-icon name="upload-cloud" class="size-5 text-gray-500" />
<div class="text-sm text-gray-600 dark:text-gray-400">{{ __('Drop to Upload') }}</div>
</div>

<div class="border border-gray-400 dark:border-gray-700 border-dashed rounded-xl p-4 flex flex-col @2xs:flex-row items-center gap-4" :class="{ 'rounded-b-none': files.length }">
<div class="text-sm text-gray-600 dark:text-gray-400 flex items-center flex-1 justify-center">
<ui-icon name="upload-cloud" class="size-5 text-gray-500 me-2" />
<span v-text="`${__('Drag & drop here or')}&nbsp;`" />
<button type="button" class="underline underline-offset-2 cursor-pointer hover:text-gray-925 dark:hover:text-gray-200" :disabled="isReadOnly" @click.prevent="browse">
{{ __('choose a file') }}
</button>
<span>.</span>
</div>
</div>

<input
ref="input"
:id="id"
type="file"
:name="name"
:multiple="config.max_files !== 1"
class="hidden"
:disabled="isReadOnly"
@change="filesSelected"
>
</div>
</div>

<div v-if="files.length" class="relative overflow-hidden rounded-xl border border-gray-300 dark:border-gray-700" :class="{ 'border-t-0! rounded-t-none': canUpload }">
<table class="w-full">
<tbody>
<tr
v-for="(file, i) in files"
:key="i"
class="asset-row bg-white hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-900"
>
<td class="flex gap-2 sm:gap-3 h-full items-center p-3">
<div class="flex size-7 items-center justify-center whitespace-nowrap">
<file-icon :extension="getExtension(file.filename)" class="size-7" />
</div>
<div class="flex w-full flex-1 items-center truncate text-sm text-gray-600 dark:text-gray-400 text-start">
<a v-if="file.download_url" :href="file.download_url" class="underline underline-offset-2">{{ file.filename }}</a>
<span v-else v-text="file.filename" />
</div>
</td>
<td v-if="file.size" class="p-3 align-middle text-end text-sm text-gray-500 whitespace-nowrap">
{{ file.size }}
</td>
<td v-if="!isLocked" class="p-3 align-middle text-end">
<ui-button
:disabled="isReadOnly"
@click="remove(i)"
icon="x"
round
size="xs"
variant="ghost"
:aria-label="__('Remove')"
:title="__('Remove')"
/>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else-if="!canUpload" class="text-sm text-gray-500" v-text="__('No files')" />
</div>
</template>

<script>
import Fieldtype from './Fieldtype.vue';

export default {
mixins: [Fieldtype],

data() {
return {
dragging: false,
};
},

computed: {
selectedFiles() {
if (!this.value) return [];

return Array.isArray(this.value) ? this.value : [this.value];
},

hasPendingSelection() {
return this.selectedFiles.some(file => file instanceof File);
},

// `value` is the source of truth for what's currently attached — a real File object
// for a fresh, unsaved selection, or a plain string once it's been processed server-side.
// `meta.files` is only used to enrich a still-present entry with size/download data; it's
// never used to decide whether an entry exists, otherwise a removed file's stale meta
// would keep its row on screen after the value's been cleared.
files() {
return this.selectedFiles.map((file, i) => {
if (file instanceof File) {
return { filename: file.name };
}

return this.meta.files?.[i] ?? { filename: typeof file === 'string' ? file.split('/').pop() : String(file) };
});
},

// True once the field is genuinely locked from any interaction — permanently read-only
// (viewing a finalized submission), or transiently read-only (mid-submit) with nothing
// pending to keep showing.
isLocked() {
return this.isReadOnly && !this.hasPendingSelection;
},

// The dropzone only makes sense while more files can still be added: not locked, and
// not already at a single-file field's limit (removing the existing file makes room again).
canUpload() {
return !this.isLocked && (this.config.max_files !== 1 || this.selectedFiles.length === 0);
},
},

methods: {
browse() {
this.$refs.input.click();
},

filesSelected(event) {
this.addFiles(event.target.files);
event.target.value = null;
},

dragenter(e) {
e.stopPropagation();
e.preventDefault();
this.dragging = true;
},

dragover(e) {
e.stopPropagation();
e.preventDefault();
},

dragleave(e) {
// When dragging over a child, the parent will trigger a dragleave.
if (e.target !== e.currentTarget) return;

this.dragging = false;
},

drop(e) {
e.stopPropagation();
e.preventDefault();
this.dragging = false;

this.addFiles(e.dataTransfer.files);
},

addFiles(fileList) {
const files = Array.from(fileList);

if (this.config.max_files === 1) {
this.update(files[0] ?? this.value);
return;
}

const combined = [...this.selectedFiles, ...files];

this.update(this.config.max_files ? combined.slice(0, this.config.max_files) : combined);
},

remove(index) {
if (this.config.max_files === 1) {
this.update(null);
return;
}

this.update([...this.selectedFiles.slice(0, index), ...this.selectedFiles.slice(index + 1)]);
},

getExtension(filename) {
return filename?.split('.').pop() ?? '';
},
},
};
</script>
2 changes: 1 addition & 1 deletion resources/views/forms/automagic-email.antlers.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{{ fields }}
<b>{{ display }}:</b>
{{ if value }}
{{ if fieldtype == "assets" }}
{{ if (fieldtype == "form_upload" && config:store) || fieldtype == "assets" }}
{{ value }}
{{ permalink }}
{{ if !last }},{{ /if }}
Expand Down
2 changes: 1 addition & 1 deletion src/Fields/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ private function fieldRules()
}

return $this->preProcessedFields()->all()->reduce(function ($carry, $field) {
if (request()->isPrecognitive() && $field->type() == 'assets') {
if (request()->isPrecognitive() && in_array($field->type(), ['assets', 'files', 'form_upload'], true)) {
return $carry;
}

Expand Down
Loading
Loading