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 > - + + + + 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()); 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); + } +}