diff --git a/config/forms.php b/config/forms.php
index c2d434f0289..563b14b40e0 100644
--- a/config/forms.php
+++ b/config/forms.php
@@ -35,6 +35,18 @@
'send_email_job' => \Statamic\Forms\SendEmail::class,
+ /*
+ |--------------------------------------------------------------------------
+ | Incomplete Submissions
+ |--------------------------------------------------------------------------
+ |
+ | Incomplete (partial) submissions are automatically deleted after a set
+ | number of days. Set this to null to prevent their automatic deletion.
+ |
+ */
+
+ 'delete_incomplete_submissions_after' => 7,
+
/*
|--------------------------------------------------------------------------
| Exporters
diff --git a/resources/js/components/forms/SubmissionListing.vue b/resources/js/components/forms/SubmissionListing.vue
index 3ca80b6df83..da157c20b5d 100644
--- a/resources/js/components/forms/SubmissionListing.vue
+++ b/resources/js/components/forms/SubmissionListing.vue
@@ -9,12 +9,17 @@
:sort-direction="sortDirection"
:preferences-prefix="preferencesPrefix"
:filters="filters"
+ push-query
>
-
-
-
+
+
+
+
+
+
+
@@ -22,11 +27,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/Publish/Form.vue b/resources/js/components/ui/Publish/Form.vue
index 8c156bf866a..35c4b22a99d 100644
--- a/resources/js/components/ui/Publish/Form.vue
+++ b/resources/js/components/ui/Publish/Form.vue
@@ -87,6 +87,9 @@ onUnmounted(() => saveKeyBinding.destroy());
+ >
+
+
+ {{ formattedDate }}
+
+
diff --git a/routes/web.php b/routes/web.php
index 1ff39175c4f..683e303b1e2 100755
--- a/routes/web.php
+++ b/routes/web.php
@@ -27,6 +27,7 @@
use Statamic\Http\Middleware\AuthGuard;
use Statamic\Http\Middleware\CP\AuthGuard as CPAuthGuard;
use Statamic\Http\Middleware\CP\HandleInertiaRequests;
+use Statamic\Http\Middleware\HandleFormPrecognitiveRequests;
use Statamic\Http\Middleware\RedirectIfTwoFactorSetupIncomplete;
use Statamic\Http\Middleware\RequireElevatedSession;
use Statamic\Statamic;
@@ -36,7 +37,7 @@
Route::name('statamic.')->group(function () {
Route::group(['prefix' => config('statamic.routes.action')], function () {
- Route::post('forms/{form}', [FormController::class, 'submit'])->middleware([HandlePrecognitiveRequests::class, 'throttle:statamic.forms'])->name('forms.submit');
+ Route::post('forms/{form}', [FormController::class, 'submit'])->middleware([HandleFormPrecognitiveRequests::class, 'throttle:statamic.forms'])->name('forms.submit');
Route::get('protect/password', [PasswordProtectController::class, 'show'])->name('protect.password.show')->middleware([HandleInertiaRequests::class]);
Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store');
diff --git a/src/Actions/MarkNotSpam.php b/src/Actions/MarkNotSpam.php
new file mode 100644
index 00000000000..4e869fa8db0
--- /dev/null
+++ b/src/Actions/MarkNotSpam.php
@@ -0,0 +1,47 @@
+isSpam();
+ }
+
+ public function authorize($user, $submission)
+ {
+ return $user->can('delete', $submission);
+ }
+
+ public function confirmationText()
+ {
+ /** @translation */
+ return 'Are you sure you want to mark this submission as not spam?|Are you sure you want to mark these :count submissions as not spam?';
+ }
+
+ public function buttonText()
+ {
+ /** @translation */
+ return 'Mark As Not Spam|Mark :count as Not Spam';
+ }
+
+ public function run($submissions, $values)
+ {
+ $submissions->each->complete();
+
+ return trans_choice('Submission marked as not spam|Submissions marked as not spam', $submissions->count());
+ }
+}
diff --git a/src/Exceptions/SilentFormFailureException.php b/src/Exceptions/SilentFormFailureException.php
index 1f5dfcaeea3..39b19737cfa 100644
--- a/src/Exceptions/SilentFormFailureException.php
+++ b/src/Exceptions/SilentFormFailureException.php
@@ -2,7 +2,17 @@
namespace Statamic\Exceptions;
+use Statamic\Contracts\Forms\Submission;
+
class SilentFormFailureException extends \Exception
{
- //
+ public function __construct(protected ?Submission $submission = null)
+ {
+ parent::__construct();
+ }
+
+ public function submission(): ?Submission
+ {
+ return $this->submission;
+ }
}
diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php
index 1ce97715656..dba1546141c 100644
--- a/src/Forms/Submission.php
+++ b/src/Forms/Submission.php
@@ -19,6 +19,7 @@
use Statamic\Facades\Asset;
use Statamic\Facades\File;
use Statamic\Facades\FormSubmission;
+use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Forms\Uploaders\AssetsUploader;
use Statamic\Forms\Uploaders\FilesUploader;
@@ -114,6 +115,25 @@ public function date()
return Carbon::createFromTimestamp($this->id());
}
+ public function isIncomplete(): bool
+ {
+ return (bool) $this->get('incomplete');
+ }
+
+ public function isSpam(): bool
+ {
+ return (bool) $this->get('spam');
+ }
+
+ public function status(): string
+ {
+ return match (true) {
+ $this->isSpam() => 'spam',
+ $this->isIncomplete() => 'incomplete',
+ default => 'complete',
+ };
+ }
+
/**
* Upload files and return asset IDs.
*
@@ -152,6 +172,10 @@ public function save()
{
$isNew = is_null($this->form()->submission($this->id()));
+ // Incomplete and spam submissions are stored but skip the Creating/Created
+ // events so listeners never receive an incomplete submission.
+ $isWithheld = $this->isIncomplete() || $this->isSpam();
+
$withEvents = $this->withEvents;
$this->withEvents = true;
@@ -159,7 +183,7 @@ public function save()
$this->afterSaveCallbacks = [];
if ($withEvents) {
- if ($isNew && SubmissionCreating::dispatch($this) === false) {
+ if ($isNew && ! $isWithheld && SubmissionCreating::dispatch($this) === false) {
return false;
}
@@ -175,7 +199,7 @@ public function save()
}
if ($withEvents) {
- if ($isNew) {
+ if ($isNew && ! $isWithheld) {
SubmissionCreated::dispatch($this);
}
@@ -183,6 +207,28 @@ public function save()
}
}
+ public function complete()
+ {
+ $existed = ! is_null($this->form()->submission($this->id()));
+
+ $this->remove('incomplete')->remove('spam');
+
+ if ($this->form()->store()) {
+ $this->save();
+
+ // A promoted incomplete already existed, so save() won't fire the created
+ // event. We dispatch it here so completion always emits it once.
+ if ($existed) {
+ SubmissionCreated::dispatch($this);
+ }
+ } else {
+ SubmissionCreated::dispatch($this);
+ }
+
+ // TODO: Use $this->site() here when we add the "site" key to submissions.
+ SendEmails::dispatch($this, Site::default());
+ }
+
public function deleteQuietly()
{
$this->withEvents = false;
diff --git a/src/Forms/SubmitForm.php b/src/Forms/SubmitForm.php
new file mode 100644
index 00000000000..ea240eaa27a
--- /dev/null
+++ b/src/Forms/SubmitForm.php
@@ -0,0 +1,169 @@
+form = $form;
+
+ return $this;
+ }
+
+ public function resume(Submission $submission): static
+ {
+ $this->submission = $submission;
+
+ return $this;
+ }
+
+ public function submit(array $data, array $files = [], ?array $only = null): Submission
+ {
+ $files = $this->normalizeFiles($files);
+ $values = array_merge($data, $files);
+
+ $this->validate($data, $files, $only);
+
+ $submission = $this->submission ?? $this->form->makeSubmission();
+
+ $uploadedAssets = [];
+
+ try {
+ throw_if(Arr::get($values, $this->form->honeypot()), new SilentFormFailureException($submission));
+
+ $uploadedAssets = $submission->uploadFiles($files);
+
+ $values = array_merge($values, $uploadedAssets);
+
+ $processedValues = $this->form->blueprint()
+ ->fields()
+ ->addValues($values)
+ ->process()
+ ->values()
+ ->when($this->submission, fn ($processedValues) => $processedValues->only(array_keys($values)));
+
+ $submission->merge($processedValues);
+
+ throw_if(FormSubmitted::dispatch($submission) === false, new SilentFormFailureException($submission));
+ } catch (ValidationException|SilentFormFailureException $e) {
+ $this->removeUploadedAssets($uploadedAssets);
+
+ throw $e;
+ }
+
+ $submission->complete();
+
+ return $submission;
+ }
+
+ public function validate(array $data, array $files = [], ?array $only = null): void
+ {
+ $files = $this->normalizeFiles($files);
+ $fields = $this->form->blueprint()->fields()->addValues(array_merge($data, $files));
+
+ $validator = $fields
+ ->validator()
+ ->withRules($this->extraRules($fields))
+ ->validator();
+
+ if ($only !== null) {
+ $validator->setRules($this->filterRules($validator->getRulesWithoutPlaceholders(), $only));
+ }
+
+ // If this was submitted from a front-end form, we want to use the appropriate language
+ // for the translation messages. If there's no previous url, it was likely submitted
+ // directly in a headless format. In that case, we'll just use the default lang.
+ $this->withLocale($this->site()?->lang(), fn () => $validator->validate());
+ }
+
+ private function extraRules($fields): array
+ {
+ return $fields->all()
+ ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets')
+ ->mapWithKeys(fn ($field) => [$field->handle().'.*' => ['file', new AllowedFile]])
+ ->all();
+ }
+
+ private function filterRules(array $rules, array $only): array
+ {
+ return collect($rules)
+ ->filter(fn ($rule, $attribute) => $this->shouldValidate($attribute, $only))
+ ->all();
+ }
+
+ private function shouldValidate(string $attribute, array $only): bool
+ {
+ foreach ($only as $pattern) {
+ $regex = '/^'.str_replace('\*', '[^.]+', preg_quote($pattern, '/')).'$/';
+
+ if (preg_match($regex, $attribute)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Normalize uploaded files to arrays.
+ *
+ * The assets fieldtype expects arrays, even for `max_files: 1`,
+ * but we don't want to force that on the front end.
+ */
+ private function normalizeFiles(array $files): array
+ {
+ $assetFields = $this->form->blueprint()->fields()->all()
+ ->filter(fn ($field) => in_array($field->fieldtype()->handle(), ['assets', 'files']))
+ ->keys();
+
+ foreach ($assetFields as $handle) {
+ if (isset($files[$handle])) {
+ $files[$handle] = Arr::wrap($files[$handle]);
+ }
+ }
+
+ return $files;
+ }
+
+ /**
+ * Remove any uploaded assets.
+ *
+ * Triggered by a validation exception or silent failure.
+ */
+ private function removeUploadedAssets(array $assets): void
+ {
+ collect($assets)
+ ->flatten()
+ ->each(function ($id) {
+ if ($asset = Asset::find($id)) {
+ $asset->delete();
+ }
+ });
+ }
+
+ private function site()
+ {
+ $previousUrl = ($referrer = request()->header('referer'))
+ ? url()->to($referrer)
+ : session()->previousUrl();
+
+ return $previousUrl ? Site::findByUrl($previousUrl) : null;
+ }
+}
diff --git a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
index 5eecb60a03a..e91c8ee77fc 100644
--- a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
+++ b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
@@ -36,6 +36,7 @@ public function index(FilteredRequest $request, $form)
$columns = $form
->blueprint()
->columns()
+ ->prepend(Column::make('status'), 'status')
->prepend(Column::make('datestamp'), 'datestamp')
->setPreferred("forms.{$form->handle()}.columns")
->rejectUnlisted()
@@ -131,6 +132,7 @@ public function show($form, $submission)
'form' => $form,
'id' => $submission->id(),
'formTitle' => $form->title(),
+ 'status' => $submission->status(),
'date' => $submission->date()->toIso8601String(),
'blueprint' => $blueprint->toPublishArray(),
'values' => $fields->values(),
diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php
index 1b151be7c80..9d80fe3fe4e 100644
--- a/src/Http/Controllers/FormController.php
+++ b/src/Http/Controllers/FormController.php
@@ -2,93 +2,90 @@
namespace Statamic\Http\Controllers;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
use Illuminate\Http\Response;
-use Illuminate\Support\Facades\URL;
use Illuminate\Support\MessageBag;
use Illuminate\Validation\ValidationException;
use Statamic\Contracts\Forms\Submission;
-use Statamic\Events\FormSubmitted;
-use Statamic\Events\SubmissionCreated;
use Statamic\Exceptions\SilentFormFailureException;
-use Statamic\Facades\Asset;
use Statamic\Facades\Form;
-use Statamic\Facades\Site;
use Statamic\Forms\Exceptions\FileContentTypeRequiredException;
-use Statamic\Forms\SendEmails;
-use Statamic\Http\Requests\FrontendFormRequest;
+use Statamic\Forms\SubmitForm;
use Statamic\Support\Arr;
use Statamic\Support\Str;
+use Statamic\Support\Traits\Hookable;
+use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use function Statamic\trans as __;
class FormController extends Controller
{
- /**
- * Handle a form submission request.
- *
- * @return mixed
- */
- public function submit(FrontendFormRequest $request, $form)
+ use Hookable;
+
+ public function submit(Request $request, $form, SubmitForm $action)
{
- $site = Site::findByUrl(URL::previous()) ?? Site::default();
- $fields = $form->blueprint()->fields();
$this->validateContentType($request, $form);
- $values = $request->all();
- $values = array_merge($values, $assets = $request->assets());
- $params = collect($request->all())->filter(function ($value, $key) {
- return Str::startsWith($key, '_');
- })->all();
+ $action->form($form);
- $fields = $fields->addValues($values);
-
- $submission = $form->makeSubmission();
+ $params = $this->params($request);
try {
- throw_if(Arr::get($values, $form->honeypot()), new SilentFormFailureException);
+ // Precognition is reimplemented here now that FrontendFormRequest is gone.
+ // We validate (scoped to the requested fields) through the action and halt
+ // without persisting, mirroring Laravel's FormRequest precognition.
+ if ($request->isPrecognitive()) {
+ $action->validate($request->all(), $request->allFiles(), only: $this->precognitiveFields($request));
+
+ return response()->noContent(headers: ['Precognition-Success' => 'true']);
+ }
- $uploadedAssets = $submission->uploadFiles($assets);
+ // Forms Pro uses this hook to create incomplete submissions and return
+ // non-standard responses (for multipage forms).
+ $result = $this->runHooks('submitting', [
+ 'request' => $request,
+ 'form' => $form,
+ 'action' => $action,
+ ]);
- $values = array_merge($values, $uploadedAssets);
+ if ($result instanceof SymfonyResponse) {
+ return $result;
+ }
- $submission->data(
- $fields->addValues($values)->process()->values()
- );
+ $submission = $result instanceof Submission
+ ? $result
+ : $action->submit($request->all(), $request->allFiles());
- // If any event listeners return false, we'll do a silent failure.
- // If they want to add validation errors, they can throw an exception.
- throw_if(FormSubmitted::dispatch($submission) === false, new SilentFormFailureException);
+ return $this->formSuccess($params, $submission);
+ } catch (SilentFormFailureException $e) {
+ return $this->formSuccess($params, $e->submission(), silentFailure: true);
} catch (ValidationException $e) {
- $this->removeUploadedAssets($uploadedAssets);
-
return $this->formFailure($params, $e->errors(), $form->handle());
- } catch (SilentFormFailureException $e) {
- if (isset($uploadedAssets)) {
- $this->removeUploadedAssets($uploadedAssets);
- }
-
- return $this->formSuccess($params, $submission, true);
}
+ }
- if ($form->store()) {
- $submission->save();
- } else {
- // When the submission is saved, this same created event will be dispatched.
- // We'll also fire it here if submissions are not configured to be stored
- // so that developers may continue to listen and modify it as needed.
- SubmissionCreated::dispatch($submission);
- }
+ private function params(Request $request): array
+ {
+ return collect($request->all())
+ ->filter(fn ($value, string $key) => Str::startsWith($key, '_'))
+ ->all();
+ }
- SendEmails::dispatch($submission, $site);
+ private function precognitiveFields(Request $request): ?array
+ {
+ if (! $request->headers->has('Precognition-Validate-Only')) {
+ return null;
+ }
- return $this->formSuccess($params, $submission);
+ return explode(',', $request->header('Precognition-Validate-Only'));
}
- private function validateContentType($request, $form)
+ private function validateContentType(Request $request, $form): void
{
$type = Str::before($request->headers->get('CONTENT_TYPE'), ';');
- if ($type !== 'multipart/form-data' && $form->hasFiles() && $request->assets()) {
+ if ($type !== 'multipart/form-data' && $form->hasFiles() && $request->allFiles()) {
throw new FileContentTypeRequiredException;
}
}
@@ -175,20 +172,4 @@ private function formSuccessRedirect($params, $submission)
return $redirect;
}
-
- /**
- * Remove any uploaded assets
- *
- * Triggered by a validation exception or silent failure
- */
- private function removeUploadedAssets(array $assets)
- {
- collect($assets)
- ->flatten()
- ->each(function ($id) {
- if ($asset = Asset::find($id)) {
- $asset->delete();
- }
- });
- }
}
diff --git a/src/Http/Middleware/HandleFormPrecognitiveRequests.php b/src/Http/Middleware/HandleFormPrecognitiveRequests.php
new file mode 100644
index 00000000000..ee8e8487bd9
--- /dev/null
+++ b/src/Http/Middleware/HandleFormPrecognitiveRequests.php
@@ -0,0 +1,24 @@
+attributes->set('precognitive', true);
+ }
+}
diff --git a/src/Http/Requests/FrontendFormRequest.php b/src/Http/Requests/FrontendFormRequest.php
deleted file mode 100644
index 795e042044c..00000000000
--- a/src/Http/Requests/FrontendFormRequest.php
+++ /dev/null
@@ -1,134 +0,0 @@
-assets;
- }
-
- /**
- * Determine if the user is authorized to make this request.
- */
- public function authorize(): bool
- {
- return true;
- }
-
- /**
- * Optionally override the redirect url based on the presence of _error_redirect
- */
- protected function getRedirectUrl()
- {
- $url = $this->redirector->getUrlGenerator();
-
- if ($redirect = $this->input('_error_redirect')) {
- return URL::isExternalToApplication($redirect) ? $url->previous() : $url->to($redirect);
- }
-
- return $url->previous();
- }
-
- public function validator()
- {
- $fields = $this->getFormFields();
-
- return $fields
- ->validator()
- ->withRules($this->extraRules($fields))
- ->validator();
- }
-
- protected function failedValidation(Validator $validator)
- {
- if ($this->ajax()) {
-
- $errors = $validator->errors();
-
- $response = response([
- 'errors' => $errors->all(),
- 'error' => collect($errors->messages())->map(function ($errors, $field) {
- return $errors[0];
- })->all(),
- ], 400);
-
- throw (new ValidationException($validator, $response));
- }
-
- return parent::failedValidation($validator);
- }
-
- private function extraRules($fields)
- {
- return $fields->all()
- ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets')
- ->mapWithKeys(function ($field) {
- return [$field->handle().'.*' => ['file', new AllowedFile]];
- })
- ->all();
- }
-
- private function getFormFields()
- {
- if ($this->cachedFields) {
- return $this->cachedFields;
- }
-
- $form = $this->route()->parameter('form');
-
- $this->errorBag = 'form.'.$form->handle();
-
- $fields = $form->blueprint()->fields();
-
- $this->assets = $this->normalizeAssetsValues($fields);
-
- $values = array_merge($this->all(), $this->assets);
-
- return $this->cachedFields = $fields->addValues($values);
- }
-
- private function normalizeAssetsValues($fields)
- {
- // The assets fieldtype is expecting an array, even for `max_files: 1`, but we don't want to force that on the front end.
- return $fields->all()
- ->filter(fn ($field) => in_array($field->fieldtype()->handle(), ['assets', 'files']) && $this->hasFile($field->handle()))
- ->map(fn ($field) => Arr::wrap($this->file($field->handle())))
- ->all();
- }
-
- public function validateResolved()
- {
- // If this was submitted from a front-end form, we want to use the appropriate language
- // for the translation messages. If there's no previous url, it was likely submitted
- // directly in a headless format. In that case, we'll just use the default lang.
- $site = ($previousUrl = $this->previousUrl()) ? Site::findByUrl($previousUrl) : null;
-
- return $this->withLocale($site?->lang(), fn () => parent::validateResolved());
- }
-
- private function previousUrl()
- {
- return ($referrer = request()->header('referer'))
- ? url()->to($referrer)
- : session()->previousUrl();
- }
-}
diff --git a/src/Http/Resources/CP/Submissions/ListedSubmission.php b/src/Http/Resources/CP/Submissions/ListedSubmission.php
index 2f81734d40e..323b6995e2c 100644
--- a/src/Http/Resources/CP/Submissions/ListedSubmission.php
+++ b/src/Http/Resources/CP/Submissions/ListedSubmission.php
@@ -33,6 +33,7 @@ public function toArray($request)
$this->merge($this->values([
'datestamp' => $this->resource->date()->tz(config('app.timezone'))->toIso8601String(),
])),
+ 'status' => $this->resource->status(),
'url' => cp_route('forms.submissions.show', [$form->handle(), $this->resource->id()]),
'deleteable' => User::current()->can('delete', $this->resource),
];
@@ -42,6 +43,11 @@ protected function values($extra = [])
{
return $this->columns->mapWithKeys(function ($column) use ($extra) {
$key = $column->field;
+
+ if ($key === 'status') {
+ return ['status' => $this->resource->status()];
+ }
+
$value = $extra[$key] ?? $this->resource->get($key);
if (! $field = $this->blueprint->field($key)) {
diff --git a/src/Http/Resources/CP/Submissions/Submissions.php b/src/Http/Resources/CP/Submissions/Submissions.php
index 3b51ff3106c..fd9e89386f6 100644
--- a/src/Http/Resources/CP/Submissions/Submissions.php
+++ b/src/Http/Resources/CP/Submissions/Submissions.php
@@ -6,6 +6,8 @@
use Statamic\CP\Column;
use Statamic\Http\Resources\CP\Concerns\HasRequestedColumns;
+use function Statamic\trans as __;
+
class Submissions extends ResourceCollection
{
use HasRequestedColumns;
@@ -35,6 +37,16 @@ private function setColumns()
->columns()
->ensurePrepended(Column::make('datestamp')->label('Date'));
+ $status = Column::make('status')
+ ->label(__('Status'))
+ ->listable(true)
+ ->visible(true)
+ ->defaultVisibility(true)
+ ->defaultOrder($columns->count() + 1)
+ ->sortable(false);
+
+ $columns->put('status', $status);
+
if ($key = $this->columnPreferenceKey) {
$columns->setPreferred($key);
}
diff --git a/src/Jobs/DeleteIncompleteFormSubmissions.php b/src/Jobs/DeleteIncompleteFormSubmissions.php
new file mode 100644
index 00000000000..75813ad0be5
--- /dev/null
+++ b/src/Jobs/DeleteIncompleteFormSubmissions.php
@@ -0,0 +1,30 @@
+subDays($days);
+
+ FormSubmission::query()
+ ->where('incomplete', true)
+ ->where('date', '<', $threshold)
+ ->get()
+ ->each
+ ->delete();
+ }
+}
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php
index ea221a1bea6..a16471f21d6 100644
--- a/src/Providers/AppServiceProvider.php
+++ b/src/Providers/AppServiceProvider.php
@@ -20,6 +20,7 @@
use Statamic\Facades\Token;
use Statamic\Facades\User;
use Statamic\Fields\FieldsetRecursionStack;
+use Statamic\Jobs\DeleteIncompleteFormSubmissions;
use Statamic\Jobs\HandleEntrySchedule;
use Statamic\Notifications\ElevatedSessionVerificationCode;
use Statamic\Sites\Sites;
@@ -141,6 +142,7 @@ public function boot()
$this->registerElevatedSessionMacros();
$this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute();
+ $this->app->make(Schedule::class)->job(DeleteIncompleteFormSubmissions::class)->daily();
}
public function register()
diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php
index 51f32fd4e09..0616624794d 100644
--- a/src/Providers/ExtensionServiceProvider.php
+++ b/src/Providers/ExtensionServiceProvider.php
@@ -34,6 +34,7 @@ class ExtensionServiceProvider extends ServiceProvider
Actions\Delete::class,
Actions\DeleteFakeSubmissions::class,
Actions\DeleteMultisiteEntry::class,
+ Actions\MarkNotSpam::class,
Actions\DisableTwoFactorAuthentication::class,
Actions\DownloadAsset::class,
Actions\DownloadAssetFolder::class,
@@ -200,6 +201,7 @@ class ExtensionServiceProvider extends ServiceProvider
Scopes\Filters\Fields::class,
Scopes\Filters\Blueprint::class,
Scopes\Filters\Status::class,
+ Scopes\Filters\SubmissionStatus::class,
Scopes\Filters\Site::class,
Scopes\Filters\UserRole::class,
Scopes\Filters\UserGroup::class,
diff --git a/src/Query/Scopes/Filters/SubmissionStatus.php b/src/Query/Scopes/Filters/SubmissionStatus.php
new file mode 100644
index 00000000000..a7af9043cc0
--- /dev/null
+++ b/src/Query/Scopes/Filters/SubmissionStatus.php
@@ -0,0 +1,60 @@
+ [
+ 'type' => 'radio',
+ 'options' => $this->options()->all(),
+ ],
+ ];
+ }
+
+ public function autoApply()
+ {
+ return ['status' => 'complete'];
+ }
+
+ public function apply($query, $values)
+ {
+ match ($values['status']) {
+ 'incomplete' => $query->where('incomplete', true),
+ 'spam' => $query->where('spam', true),
+ default => $query->where('incomplete', '!=', true)->where('spam', '!=', true),
+ };
+ }
+
+ public function badge($values)
+ {
+ return $this->options()->get($values['status']);
+ }
+
+ public function visibleTo($key)
+ {
+ return $key === 'form-submissions';
+ }
+
+ protected function options()
+ {
+ return collect([
+ 'complete' => __('Complete'),
+ 'incomplete' => __('Incomplete'),
+ 'spam' => __('Spam'),
+ ]);
+ }
+}
diff --git a/tests/Actions/MarkNotSpamTest.php b/tests/Actions/MarkNotSpamTest.php
new file mode 100644
index 00000000000..e3dac458411
--- /dev/null
+++ b/tests/Actions/MarkNotSpamTest.php
@@ -0,0 +1,55 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ #[Test]
+ public function it_is_only_visible_to_spam_submissions()
+ {
+ $form = tap(Form::make('contact'))->save();
+
+ $action = new MarkNotSpam;
+
+ $this->assertFalse($action->visibleTo($form->makeSubmission()));
+ $this->assertFalse($action->visibleTo($form->makeSubmission()->set('incomplete', true)));
+ $this->assertTrue($action->visibleTo($form->makeSubmission()->set('spam', true)));
+ }
+
+ #[Test]
+ public function it_removes_the_spam_key_and_dispatches_relevant_events()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $form = tap(Form::make('contact'))->save();
+ $submission = $form->makeSubmission()->set('spam', true);
+ $submission->save();
+
+ (new MarkNotSpam)->run(collect([$submission]), []);
+
+ $this->assertFalse($submission->isSpam());
+
+ Event::assertDispatched(SubmissionCreated::class);
+ Bus::assertDispatched(SendEmails::class);
+ }
+}
diff --git a/tests/Forms/DeleteIncompleteFormSubmissionsTest.php b/tests/Forms/DeleteIncompleteFormSubmissionsTest.php
new file mode 100644
index 00000000000..ed25ac2b853
--- /dev/null
+++ b/tests/Forms/DeleteIncompleteFormSubmissionsTest.php
@@ -0,0 +1,89 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ #[Test]
+ public function it_deletes_incomplete_submissions_older_than_the_configured_threshold()
+ {
+ config(['statamic.forms.delete_incomplete_submissions_after' => 7]);
+
+ $form = tap(Form::make('contact'))->save();
+
+ Carbon::setTestNow('2025-06-01 12:00:00');
+ $oldIncomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-02 12:00:00');
+ $oldComplete = tap($form->makeSubmission())->save();
+
+ Carbon::setTestNow('2025-06-14 12:00:00');
+ $recentIncomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-15 12:00:00');
+
+ (new DeleteIncompleteFormSubmissions)->handle();
+
+ $this->assertNull($form->submission($oldIncomplete->id()));
+ $this->assertNotNull($form->submission($recentIncomplete->id()));
+ $this->assertNotNull($form->submission($oldComplete->id()));
+ }
+
+ #[Test]
+ public function it_only_deletes_incomplete_submissions_never_complete_or_spam()
+ {
+ config(['statamic.forms.delete_incomplete_submissions_after' => 7]);
+
+ $form = tap(Form::make('contact'))->save();
+
+ Carbon::setTestNow('2025-06-01 12:00:00');
+ $incomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-02 12:00:00');
+ $complete = tap($form->makeSubmission())->save();
+
+ Carbon::setTestNow('2025-06-03 12:00:00');
+ $spam = tap($form->makeSubmission()->set('spam', true))->save();
+
+ Carbon::setTestNow('2025-06-30 12:00:00');
+
+ (new DeleteIncompleteFormSubmissions)->handle();
+
+ $this->assertNull($form->submission($incomplete->id()));
+ $this->assertNotNull($form->submission($complete->id()));
+ $this->assertNotNull($form->submission($spam->id()));
+ }
+
+ #[Test]
+ public function it_does_not_delete_anything_when_disabled()
+ {
+ config(['statamic.forms.delete_incomplete_submissions_after' => null]);
+
+ $form = tap(Form::make('contact'))->save();
+
+ Carbon::setTestNow('2025-06-01 12:00:00');
+ $incomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-30 12:00:00');
+
+ (new DeleteIncompleteFormSubmissions)->handle();
+
+ $this->assertNotNull($form->submission($incomplete->id()));
+ }
+}
diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php
index d19fde2c5d3..f8515e7cbc6 100644
--- a/tests/Forms/SubmissionTest.php
+++ b/tests/Forms/SubmissionTest.php
@@ -4,6 +4,7 @@
use Carbon\Carbon;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@@ -13,6 +14,8 @@
use Statamic\Events\SubmissionSaved;
use Statamic\Events\SubmissionSaving;
use Statamic\Facades\Form;
+use Statamic\Facades\Site;
+use Statamic\Forms\SendEmails;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -240,6 +243,129 @@ public function it_deletes_quietly()
$this->assertTrue($return);
}
+ #[Test]
+ public function it_determines_its_status()
+ {
+ $form = tap(Form::make('contact_us'))->save();
+
+ $submitted = $form->makeSubmission();
+ $this->assertFalse($submitted->isIncomplete());
+ $this->assertFalse($submitted->isSpam());
+ $this->assertEquals('complete', $submitted->status());
+
+ $incomplete = $form->makeSubmission()->set('incomplete', true);
+ $this->assertTrue($incomplete->isIncomplete());
+ $this->assertFalse($incomplete->isSpam());
+ $this->assertEquals('incomplete', $incomplete->status());
+
+ $spam = $form->makeSubmission()->set('spam', true);
+ $this->assertTrue($spam->isSpam());
+ $this->assertFalse($spam->isIncomplete());
+ $this->assertEquals('spam', $spam->status());
+ }
+
+ #[Test]
+ #[DataProvider('withheldStatusProvider')]
+ public function it_does_not_dispatch_creation_events_when_saving_a_withheld_submission(string $status)
+ {
+ Event::fake();
+
+ $form = tap(Form::make('contact_us'))->save();
+
+ $submission = $form->makeSubmission()->set($status, true);
+ $submission->save();
+
+ // Creation events shouldn't be dispatched.
+ Event::assertNotDispatched(SubmissionCreating::class);
+ Event::assertNotDispatched(SubmissionCreated::class);
+
+ // But, saving events should.
+ Event::assertDispatched(SubmissionSaving::class);
+ Event::assertDispatched(SubmissionSaved::class);
+ }
+
+ public static function withheldStatusProvider(): array
+ {
+ return [
+ 'incomplete' => ['incomplete'],
+ 'spam' => ['spam'],
+ ];
+ }
+
+ #[Test]
+ public function created_event_is_not_automatically_dispatched_when_removing_the_incomplete_key()
+ {
+ $form = tap(Form::make('contact_us'))->save();
+
+ $submission = $form->makeSubmission()->set('incomplete', true);
+ $submission->save();
+
+ Event::fake();
+
+ // Removing the incomplete key turns it into a "real" submission, but because
+ // the record already exists, save() alone won't dispatch Created. This is why
+ // complete() dispatches it explicitly (covered by the test below).
+ $submission->remove('incomplete');
+ $submission->save();
+
+ Event::assertNotDispatched(SubmissionCreating::class);
+ Event::assertNotDispatched(SubmissionCreated::class);
+ Event::assertDispatched(SubmissionSaved::class);
+ }
+
+ #[Test]
+ public function completing_a_new_submission_dispatches_created_event_once()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $form = tap(Form::make('contact_us'))->save();
+ $submission = $form->makeSubmission();
+
+ $submission->complete(Site::default());
+
+ Event::assertDispatched(SubmissionCreated::class, 1);
+ Bus::assertDispatched(SendEmails::class, 1);
+
+ $this->assertNotNull($form->submission($submission->id()));
+ }
+
+ #[Test]
+ public function completing_an_incomplete_or_spam_submission_removes_the_status_key_and_dispatches_events()
+ {
+ $form = tap(Form::make('contact_us'))->save();
+ $submission = tap($form->makeSubmission()->set('incomplete', true)->set('spam', true))->save();
+
+ Bus::fake();
+ Event::fake([SubmissionCreated::class, SubmissionCreating::class]);
+
+ $submission->complete(Site::default());
+
+ $this->assertFalse($submission->isIncomplete());
+ $this->assertFalse($submission->isSpam());
+
+ // Submission already exists, so save() won't dispatch the Created event, complete() will.
+ Event::assertDispatched(SubmissionCreated::class, 1);
+ Event::assertNotDispatched(SubmissionCreating::class);
+ Bus::assertDispatched(SendEmails::class, 1);
+ }
+
+ #[Test]
+ public function completing_a_submission_for_a_non_storing_form_still_dispatches_the_created_event()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $form = tap(Form::make('contact_us')->store(false))->save();
+ $submission = $form->makeSubmission();
+
+ $submission->complete(Site::default());
+
+ Event::assertDispatched(SubmissionCreated::class, 1);
+ Bus::assertDispatched(SendEmails::class, 1);
+ $this->assertNull($form->submission($submission->id()));
+ }
+
#[Test]
public function it_clones_internal_collections()
{
diff --git a/tests/Forms/SubmitFormTest.php b/tests/Forms/SubmitFormTest.php
new file mode 100644
index 00000000000..405887fdb3c
--- /dev/null
+++ b/tests/Forms/SubmitFormTest.php
@@ -0,0 +1,415 @@
+form = tap(Form::make('contact')->honeypot('winnie')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'name', 'field' => ['type' => 'short_answer']],
+ ['handle' => 'email', 'field' => ['type' => 'email', 'validate' => 'required']],
+ ['handle' => 'message', 'field' => ['type' => 'long_answer']],
+ ],
+ ],
+ ],
+ ]))->save();
+ }
+
+ public function tearDown(): void
+ {
+ $this->form->submissions()->each->delete();
+
+ parent::tearDown();
+ }
+
+ private function action(): SubmitForm
+ {
+ return app(SubmitForm::class)->form($this->form);
+ }
+
+ #[Test]
+ public function it_submits_a_form_successfully()
+ {
+ Bus::fake();
+
+ $submission = $this->action()->submit(
+ data: ['name' => 'Test User', 'email' => 'test@example.com', 'message' => 'Hello'],
+ );
+
+ $this->assertNotNull($submission);
+ $this->assertEquals('Test User', $submission->get('name'));
+ $this->assertEquals('test@example.com', $submission->get('email'));
+ $this->assertEquals('Hello', $submission->get('message'));
+ }
+
+ #[Test]
+ public function it_saves_submission_when_store_is_enabled()
+ {
+ Bus::fake();
+
+ $this->assertEmpty($this->form->submissions());
+
+ $this->action()->submit(
+ data: ['email' => 'test@example.com'],
+ );
+
+ $this->assertCount(1, $this->form->submissions());
+ }
+
+ #[Test]
+ public function it_dispatches_submission_created_event_when_store_is_disabled()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $this->form->store(false);
+ $this->form->save();
+
+ $this->action()->submit(
+ data: ['email' => 'test@example.com'],
+ );
+
+ Event::assertDispatched(SubmissionCreated::class);
+ $this->assertEmpty($this->form->submissions());
+ }
+
+ #[Test]
+ public function it_throws_silent_failure_exception_when_honeypot_is_filled()
+ {
+ $this->expectException(SilentFormFailureException::class);
+
+ $this->action()->submit(
+ data: ['email' => 'test@example.com', 'winnie' => 'the pooh'],
+ );
+ }
+
+ #[Test]
+ public function it_dispatches_form_submitted_event()
+ {
+ Bus::fake();
+ Event::fake([FormSubmitted::class]);
+
+ $this->action()->submit(
+ data: ['email' => 'test@example.com'],
+ );
+
+ Event::assertDispatched(FormSubmitted::class, function ($event) {
+ return $event->submission->get('email') === 'test@example.com';
+ });
+ }
+
+ #[Test]
+ public function it_throws_silent_failure_exception_when_event_listener_returns_false()
+ {
+ Event::listen(FormSubmitted::class, fn () => false);
+
+ try {
+ $this->action()->submit(
+ data: ['email' => 'test@example.com'],
+ );
+
+ $this->fail('Expected SilentFormFailureException was not thrown');
+ } catch (SilentFormFailureException $e) {
+ $this->assertNotNull($e->submission());
+ }
+ }
+
+ #[Test]
+ public function it_throws_validation_exception_from_event_listener()
+ {
+ Event::listen(FormSubmitted::class, function () {
+ throw ValidationException::withMessages(['custom' => 'Custom validation error']);
+ });
+
+ $this->expectException(ValidationException::class);
+
+ $this->action()->submit(
+ data: ['email' => 'test@example.com'],
+ );
+ }
+
+ #[Test]
+ public function it_dispatches_send_emails()
+ {
+ Bus::fake();
+
+ $this->action()->submit(
+ data: ['email' => 'test@example.com'],
+ );
+
+ Bus::assertDispatched(SendEmails::class);
+ }
+
+ #[Test]
+ public function it_throws_validation_exception_when_validation_fails()
+ {
+ $this->expectException(ValidationException::class);
+
+ $this->action()->validate(
+ data: ['name' => 'Test'], // missing required email
+ );
+ }
+
+ #[Test]
+ public function it_throws_validation_exception_with_field_errors()
+ {
+ try {
+ $this->action()->validate(data: ['name' => 'Test']);
+
+ $this->fail('Expected ValidationException was not thrown');
+ } catch (ValidationException $e) {
+ $this->assertArrayHasKey('email', $e->errors());
+ }
+ }
+
+ #[Test]
+ public function validation_passes_with_valid_data()
+ {
+ $this->action()->validate(data: ['email' => 'test@example.com']);
+
+ $this->addToAssertionCount(1);
+ }
+
+ #[Test]
+ public function it_does_not_persist_a_submission_when_validation_fails()
+ {
+ $this->assertEmpty($this->form->submissions());
+
+ try {
+ $this->action()->submit(data: ['name' => 'Test']); // missing required email
+ } catch (ValidationException $e) {
+ // Expected
+ }
+
+ $this->assertEmpty($this->form->submissions());
+ }
+
+ #[Test]
+ public function it_scopes_validation_to_the_given_fields()
+ {
+ // The email field is required, but scoping validation to "name" only
+ // means the missing email shouldn't cause a validation failure.
+ $this->action()->validate(data: ['name' => 'Test'], only: ['name']);
+
+ $this->addToAssertionCount(1);
+ }
+
+ #[Test]
+ public function it_still_validates_scoped_fields()
+ {
+ $this->expectException(ValidationException::class);
+
+ $this->action()->validate(data: ['email' => 'not-an-email'], only: ['email']);
+ }
+
+ #[Test]
+ public function it_can_resume_an_incomplete_submission()
+ {
+ Bus::fake();
+
+ $draft = tap($this->form->makeSubmission()->data(['name' => 'Olaf', 'email' => 'old@example.com'])->set('incomplete', true))->save();
+
+ $this->assertCount(1, $this->form->submissions());
+ $this->assertTrue($this->form->submission($draft->id())->isIncomplete());
+
+ $submission = $this->action()->resume($draft)->submit(
+ data: ['email' => 'new@example.com'],
+ );
+
+ // Ensures the same submission is completed (eg. it didn't create a new one).
+ $this->assertEquals($draft->id(), $submission->id());
+ $this->assertCount(1, $this->form->submissions());
+
+ $stored = $this->form->submission($draft->id());
+ $this->assertFalse($stored->isIncomplete());
+
+ // A field present in the request should update the existing value.
+ $this->assertEquals('new@example.com', $stored->get('email'));
+
+ // A field _not_ present in the request should be persisted.
+ $this->assertEquals('Olaf', $stored->get('name'));
+ }
+
+ #[Test]
+ public function it_dispatches_created_event_once_when_completing_a_resumed_submission()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $draft = tap($this->form->makeSubmission()->data(['email' => 'old@example.com'])->set('incomplete', true))->save();
+
+ $this->action()->resume($draft)->submit(
+ data: ['email' => 'new@example.com'],
+ );
+
+ Event::assertDispatched(SubmissionCreated::class, 1);
+ Bus::assertDispatched(SendEmails::class, 1);
+ }
+
+ #[Test]
+ public function it_uploads_files()
+ {
+ Bus::fake();
+ Storage::fake('avatars');
+ AssetContainer::make('avatars')->disk('avatars')->save();
+
+ $form = tap(Form::make('uploads')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'email', 'field' => ['type' => 'email', 'validate' => 'required']],
+ ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']],
+ ],
+ ],
+ ],
+ ]), fn ($f) => $f->save());
+
+ $submission = app(SubmitForm::class)->form($form)->submit(
+ data: ['email' => 'test@example.com'],
+ files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]],
+ );
+
+ Storage::disk('avatars')->assertExists('avatar.jpg');
+
+ $form->submissions()->each->delete();
+ }
+
+ #[Test]
+ public function it_removes_uploaded_assets_on_silent_failure()
+ {
+ Storage::fake('avatars');
+ AssetContainer::make('avatars')->disk('avatars')->save();
+
+ $form = tap(Form::make('uploads')->honeypot('winnie')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']],
+ ],
+ ],
+ ],
+ ]), fn ($f) => $f->save());
+
+ try {
+ app(SubmitForm::class)->form($form)->submit(
+ data: ['email' => 'test@example.com', 'winnie' => 'the pooh'],
+ files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]],
+ );
+ } catch (SilentFormFailureException $e) {
+ // Expected
+ }
+
+ Storage::disk('avatars')->assertMissing('avatar.jpg');
+ }
+
+ #[Test]
+ public function it_removes_uploaded_assets_when_event_listener_returns_false()
+ {
+ Storage::fake('avatars');
+ AssetContainer::make('avatars')->disk('avatars')->save();
+
+ $form = tap(Form::make('uploads')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']],
+ ],
+ ],
+ ],
+ ]), fn ($f) => $f->save());
+
+ Event::listen(FormSubmitted::class, function () {
+ return false;
+ });
+
+ try {
+ app(SubmitForm::class)->form($form)->submit(
+ data: ['email' => 'test@example.com'],
+ files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]],
+ );
+ } catch (SilentFormFailureException $e) {
+ // Expected
+ }
+
+ Storage::disk('avatars')->assertMissing('avatar.jpg');
+ }
+
+ #[Test]
+ public function it_removes_uploaded_assets_on_validation_exception()
+ {
+ Storage::fake('avatars');
+ AssetContainer::make('avatars')->disk('avatars')->save();
+
+ $form = tap(Form::make('uploads')->formFields([
+ 'sections' => [
+ [
+ 'fields' => [
+ ['handle' => 'email', 'field' => ['type' => 'email']],
+ ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']],
+ ],
+ ],
+ ],
+ ]), fn ($f) => $f->save());
+
+ Event::listen(FormSubmitted::class, function () {
+ throw ValidationException::withMessages(['custom' => 'Error']);
+ });
+
+ try {
+ app(SubmitForm::class)->form($form)->submit(
+ data: ['email' => 'test@example.com'],
+ files: ['avatar' => [UploadedFile::fake()->image('avatar.jpg')]],
+ );
+ } catch (ValidationException $e) {
+ // Expected
+ }
+
+ Storage::disk('avatars')->assertMissing('avatar.jpg');
+ }
+
+ #[Test]
+ public function a_precognitive_success_does_not_persist_a_submission()
+ {
+ Bus::fake();
+
+ $this->assertEmpty($this->form->submissions());
+
+ $this
+ ->withPrecognition()
+ ->withHeaders(['Precognition-Validate-Only' => 'email'])
+ ->postJson('/!/forms/contact', ['email' => 'test@example.com'])
+ ->assertNoContent()
+ ->assertHeader('Precognition-Success', 'true');
+
+ $this->assertEmpty($this->form->submissions());
+ Bus::assertNotDispatched(SendEmails::class);
+ }
+}