diff --git a/config/forms.php b/config/forms.php index f411498097e..94f03f4cca9 100644 --- a/config/forms.php +++ b/config/forms.php @@ -47,6 +47,8 @@ 'delete_partial_submissions_after' => 7, + 'garbage_collect_assets' => false, + /* |-------------------------------------------------------------------------- | Exporters diff --git a/routes/web.php b/routes/web.php index 4fc1f673da4..580f71fb28e 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/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/Fields/FormFields.php b/src/Forms/Fields/FormFields.php index 311480363b0..4e6d995b611 100644 --- a/src/Forms/Fields/FormFields.php +++ b/src/Forms/Fields/FormFields.php @@ -35,12 +35,13 @@ public function contents(): array public function pages(): Collection { - if (isset($this->contents['pages'])) { - return collect($this->contents['pages']); - } + $pages = isset($this->contents['pages']) + ? collect($this->contents['pages']) + : collect([['sections' => $this->contents['sections'] ?? []]]); - return collect([ - ['sections' => $this->contents['sections'] ?? []], + return $pages->map(fn (array $page, int $index): array => [ + ...$page, + 'id' => $page['id'] ?? ($pages->count() === 1 ? 'main' : 'page_'.($index + 1)), ]); } @@ -78,8 +79,6 @@ public function toBlueprint(): Blueprint { $tabs = $this->pages() ->mapWithKeys(function (array $page, int $index): array { - $id = $page['id'] ?? ($this->pages()->count() === 1 ? 'main' : 'page_'.($index + 1)); - $sections = collect($page['sections'] ?? []) ->map(function (array $section): array { return [ @@ -105,8 +104,8 @@ public function toBlueprint(): Blueprint ->all(); return [ - $id => [ - ...$page, + $page['id'] => [ + ...Arr::except($page, 'id'), 'display' => $page['display'] ?? __('Page :current of :total', ['current' => $index + 1, 'total' => $this->pages()->count()]), 'sections' => $sections, ], diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 1f7028f4780..98e7550d22a 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -218,6 +218,15 @@ private function convertFieldsFromBlueprint(Blueprint $blueprint): array return ['sections' => $sections]; } + public function hasMultiplePages(): bool + { + if (! Statamic::formsProInstalled()) { + return false; + } + + return $this->formFields()->pages()->count() > 1; + } + /** * Get the blueprint. * diff --git a/src/Forms/JsDrivers/AbstractJsDriver.php b/src/Forms/JsDrivers/AbstractJsDriver.php index 76620170b2f..aab3cd2a45c 100644 --- a/src/Forms/JsDrivers/AbstractJsDriver.php +++ b/src/Forms/JsDrivers/AbstractJsDriver.php @@ -76,6 +76,18 @@ public function addToRenderableFieldAttributes($field) return []; } + /** + * Add to renderable page view data. + * + * @param \Statamic\Fields\Tab $page + * @param array $data + * @return array + */ + public function addToRenderablePageData($page, $data) + { + return []; + } + /** * Render form html. * diff --git a/src/Forms/JsDrivers/JsDriver.php b/src/Forms/JsDrivers/JsDriver.php index 9e8f61d6425..0d83d543d39 100644 --- a/src/Forms/JsDrivers/JsDriver.php +++ b/src/Forms/JsDrivers/JsDriver.php @@ -10,6 +10,8 @@ public function addToFormAttributes(); public function addToRenderableFieldData($field, $data); + // public function addToRenderablePageData($page, array $data): array; + public function addToRenderableFieldAttributes($field); public function render($html); diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php index ed42b952e36..39bcfe5fc3f 100644 --- a/src/Forms/Submission.php +++ b/src/Forms/Submission.php @@ -259,6 +259,8 @@ public function finalize() // here when submissions aren't stored so developers may continue to // listen and modify the submission as needed. SubmissionCreated::dispatch($this); + + $this->deleteQuietly(); } SubmissionFinalized::dispatch($this); diff --git a/src/Forms/SubmissionResult.php b/src/Forms/SubmissionResult.php new file mode 100644 index 00000000000..fe8de5a612b --- /dev/null +++ b/src/Forms/SubmissionResult.php @@ -0,0 +1,19 @@ +submission->status() === 'finalized'; + } +} diff --git a/src/Forms/SubmitForm.php b/src/Forms/SubmitForm.php new file mode 100644 index 00000000000..b8c9a78dc57 --- /dev/null +++ b/src/Forms/SubmitForm.php @@ -0,0 +1,210 @@ +form = $form; + + return $this; + } + + public function page(string $page): static + { + $this->page = $page; + + return $this; + } + + public function resume(Submission $submission): static + { + $this->submission = $submission; + + return $this; + } + + public function submit(array $data, array $files = []): SubmissionResult + { + $files = $this->normalizeFiles($files); + $values = array_merge($data, $files); + $uploadedAssets = []; + + $this->validate($data, $files); + + $this->submission = $this->submission ?? $this->form->makeSubmission()->asPartial()->site($this->site()); + + try { + if ($this->shouldFinalize()) { + throw_if(Arr::get($values, $this->form->honeypot()), new SilentFormFailureException($this->submission)); + } + + $uploadedAssets = $this->submission->uploadFiles($files); + + $values = array_merge($values, $uploadedAssets); + + $processedValues = $this->form->blueprint() + ->fields() + ->addValues($values) + ->process() + ->values() + ->when($this->page, fn ($fields) => $fields->only($this->fieldHandles($this->page))); + + $this->submission->merge($processedValues); + + if ($this->shouldFinalize()) { + throw_if(FormSubmitted::dispatch($this->submission) === false, new SilentFormFailureException($this->submission)); + } + } catch (ValidationException|SilentFormFailureException $e) { + $this->removeUploadedAssets($uploadedAssets); + + throw $e; + } + + $this->shouldFinalize() ? $this->submission->finalize() : $this->submission->save(); + + // todo: obviously this should depend on logic at some point + $pages = $this->form->formFields()->pages(); + $currentPageIndex = $pages->where('id', $this->page)->keys()->first(); + $nextPage = $pages->get($currentPageIndex + 1); + + return new SubmissionResult( + $this->submission, + $nextPage ? $nextPage['id'] : null + ); + } + + /** + * 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; + } + + private function site() + { + $previousUrl = ($referrer = request()->header('referer')) + ? url()->to($referrer) + : session()->previousUrl(); + + return $previousUrl ? Site::findByUrl($previousUrl) : null; + } + + private function shouldFinalize(): bool + { + if (! $this->form->hasMultiplePages()) { + return true; + } + + // todo: should take logic into account (the actual last page on the form might not be the user's last page) + $pages = $this->form->formFields()->pages(); + + return Arr::get($pages->last(), 'id') === $this->page; + } + + private function fieldHandles(string $page): array + { + return $this->form->blueprint()->tabs() + ->filter(fn ($tab): bool => $tab->handle() == $page) + ->flatMap(fn ($tab): array => $tab->sections()->flatMap(fn ($section) => $section->fields()->items()->pluck('handle'))->all()) + ->values() + ->all(); + } + + /** + * 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(); + } + }); + } + + 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 && $this->page) { + $only = $this->fieldHandles($this->page); + } + + if ($only) { + $validator->setRules($this->filterRules($validator->getRulesWithoutPlaceholders(), $only)); + } + + $this->withLocale($this->site()?->lang(), fn () => $validator->validate()); + } + + private function extraRules($fields): array + { + return $fields->all() + ->filter(fn ($field): bool => $field->fieldtype()->handle() === 'assets') + ->mapWithKeys(fn ($field): array => [$field->handle().'.*' => ['file', new AllowedFile]]) + ->all(); + } + + private function filterRules(array $rules, array $only): array + { + return collect($rules) + ->filter(fn ($rule, $attribute): bool => $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; + } +} diff --git a/src/Forms/Tags.php b/src/Forms/Tags.php index 1584bffc886..d138b51a45d 100644 --- a/src/Forms/Tags.php +++ b/src/Forms/Tags.php @@ -5,12 +5,15 @@ use DebugBar\DataCollector\ConfigCollector; use DebugBar\DebugBarException; use Illuminate\Support\Collection; +use Illuminate\Support\Uri; use Statamic\Contracts\Forms\Form as FormContract; +use Statamic\Contracts\Forms\Submission; use Statamic\Facades\Antlers; use Statamic\Facades\Blink; use Statamic\Facades\Blueprint; use Statamic\Facades\Form; -use Statamic\Facades\URL; +use Statamic\Fields\Tab; +use Statamic\Forms\JsDrivers\AbstractJsDriver; use Statamic\Forms\JsDrivers\JsDriver; use Statamic\Support\Arr; use Statamic\Support\Html; @@ -19,6 +22,8 @@ use Statamic\Tags\Tags as BaseTags; use Statamic\View\Antlers\Language\Runtime\GlobalRuntimeState; +use function Statamic\trans as __; + class Tags extends BaseTags { use Concerns\GetsFormSession, @@ -76,12 +81,23 @@ public function create() ? Blueprint::makeFromTabs($configFields)->fields()->addValues($form->data()->all())->augment()->values()->all() : []; + $data['pages'] = $this->getPages($this->sessionHandle(), $jsDriver); + + $data['page'] = Arr::except(collect($data['pages'])->firstWhere('id', Arr::get($this->currentPage(), 'id')), 'sections'); + $data['sections'] = $this->getSections($this->sessionHandle(), $jsDriver); $data['fields'] = collect($data['sections'])->flatMap->fields->all(); $data['honeypot'] = $form->honeypot(); + $data['button_label'] = Arr::get($data['page'], 'button_label'); + + if (! $this->isFirstPage()) { + $data['previous_page_label'] = Arr::get($data['page'], 'previous_page_label'); + $data['previous_page_url'] = $this->previousPageUrl(); + } + if ($jsDriver) { $data['js_driver'] = $jsDriver->handle(); $data['show_field'] = $jsDriver->copyShowFieldToFormData($data['fields']); @@ -119,6 +135,10 @@ public function create() $params['error_redirect'] = $this->parseRedirect($errorRedirect); } + if ($form->hasMultiplePages()) { + $params['page'] = Arr::get($this->currentPage(), 'id'); + } + if (! $this->canParseContents()) { return array_merge([ 'attrs' => $this->formAttrs($action, $method, $knownParams, $attrs), @@ -283,7 +303,7 @@ protected function getForm() } /** - * Get sections of fields, using sections defined in blueprint. + * Get sections of fields across all pages, using sections defined in blueprint. * * @param string $sessionHandle * @param JsDriver $jsDriver @@ -291,13 +311,73 @@ protected function getForm() */ protected function getSections($sessionHandle, $jsDriver) { - return $this->form()->blueprint()->tabs()->first()->sections() + return $this->form()->blueprint()->tabs() + ->filter(fn ($tab) => $tab->handle() === Arr::get($this->currentPage(), 'id')) + ->flatMap(fn ($tab) => $this->getSectionsForTab($tab, $sessionHandle, $jsDriver)) + ->values() + ->all(); + } + + /** + * Get the sections of fields for a single page (blueprint tab). + * + * @param \Statamic\Fields\Tab $tab + * @param string $sessionHandle + * @param JsDriver $jsDriver + * @return \Illuminate\Support\Collection + */ + private function getSectionsForTab($tab, $sessionHandle, $jsDriver) + { + return $tab->sections() ->map(function ($section) use ($sessionHandle, $jsDriver) { + $fields = $section->fields(); + + if ($partialSubmission = $this->getPartialSubmission()) { + $fields = $fields->addValues($partialSubmission->data()->all()); + } + return [ 'display' => $section->display(), 'instructions' => $section->instructions(), - 'fields' => $this->getFields($sessionHandle, $jsDriver, $section->fields()->all()), + 'fields' => $this->getFields($sessionHandle, $jsDriver, $fields->all()), ]; + }); + } + + /** + * Get pages of sections, using the tabs defined in the blueprint. + * + * @param string $sessionHandle + * @param JsDriver $jsDriver + * @return array + */ + protected function getPages($sessionHandle, $jsDriver) + { + $tabs = $this->form()->blueprint()->tabs()->values(); + + return $tabs + ->map(function (Tab $tab, $index) use ($tabs, $sessionHandle, $jsDriver) { + $contents = $tab->contents(); + $isFirstPage = $index === 0; + $isLastPage = $index === $tabs->count() - 1; + + $page = [ + 'id' => $tab->handle(), + 'display' => $tab->display(), + 'instructions' => $tab->instructions(), + 'button_label' => $contents['button_label'] ?? ($isLastPage ? __('Submit') : __('Next')), + 'sections' => $this->getSectionsForTab($tab, $sessionHandle, $jsDriver)->all(), + ]; + + if (! $isFirstPage) { + $page['previous_page_label'] = $contents['previous_page_label'] ?? null; + } + + if ($jsDriver instanceof AbstractJsDriver) { + $page = array_merge($page, $jsDriver->addToRenderablePageData($tab, $page)); + } + + return $page; }) ->all(); } @@ -435,11 +515,56 @@ protected function formHandle() return $form; } - public function eventUrl($url, $relative = true) + private function isFirstPage(): bool { - return URL::prependSiteUrl( - config('statamic.routes.action').'/form/'.$url - ); + $pages = $this->form()->formFields()->pages(); + + return Arr::get($pages->first(), 'id') === Arr::get($this->currentPage(), 'id'); + } + + // todo: take logic into account + private function isFinalPage(): bool + { + $pages = $this->form()->formFields()->pages(); + + return Arr::get($pages->last(), 'id') === Arr::get($this->currentPage(), 'id'); + } + + private function previousPageUrl(): ?string + { + if ($this->isFirstPage()) { + return null; + } + + $pages = $this->form()->formFields()->pages(); + $currentPageIndex = $pages->search(fn (array $page) => $page['id'] === Arr::get($this->currentPage(), 'id')); + $previousPage = $pages->get($currentPageIndex - 1); + + return Uri::of(url()->current())->withQuery(['page' => Arr::get($previousPage, 'id')])->__toString(); + } + + private function currentPage(): array + { + $pages = $this->form()->formFields()->pages(); + $page = $pages->first(); + + if (request()->has('page') && $pages->where('id', request()->get('page'))->count() > 0) { + $page = $pages->where('id', request()->get('page'))->first(); + } + + return $page; + } + + private function getPartialSubmission(): ?Submission + { + $id = session()->get("form.{$this->form()->handle()}.partial_submission"); + $submission = $this->form()->submission($id); + + if ($submission && ! $submission->isPartial()) { + return null; + } + + return $submission; } private function dottedContextFields(array $fields, $recursive = false, array &$dotted = []): Collection diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php index 2f7928e1dda..ae38b0e56cf 100644 --- a/src/Http/Controllers/FormController.php +++ b/src/Http/Controllers/FormController.php @@ -2,18 +2,18 @@ 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\Support\Uri; use Illuminate\Validation\ValidationException; use Statamic\Contracts\Forms\Submission; -use Statamic\Events\FormSubmitted; use Statamic\Exceptions\SilentFormFailureException; -use Statamic\Facades\Asset; use Statamic\Facades\Form; -use Statamic\Facades\Site; use Statamic\Forms\Exceptions\FileContentTypeRequiredException; -use Statamic\Http\Requests\FrontendFormRequest; +use Statamic\Forms\SubmissionResult; +use Statamic\Forms\SubmitForm; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -21,63 +21,75 @@ class FormController extends Controller { - /** - * Handle a form submission request. - * - * @return mixed - */ - public function submit(FrontendFormRequest $request, $form) + 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); + if ($form->hasMultiplePages()) { + $action->page($form->formFields()->pages()->first()['id']); - $submission = $form->makeSubmission()->asPartial()->site($site); + if ($partialSubmission = $this->getPartialSubmission($form)) { + $action->resume($partialSubmission); + } - try { - throw_if(Arr::get($values, $form->honeypot()), new SilentFormFailureException); + if ( + ($page = $request->input('_page')) && + $form->formFields()->pages()->where('id', $page)->isNotEmpty() + ) { + $action->page($page); + } + } - $uploadedAssets = $submission->uploadFiles($assets); + $params = $this->params($request); - $values = array_merge($values, $uploadedAssets); + try { + // We validate (scoped to the requested fields) through the action and halt + // without persisting, mirroring how Precognition works in Form Requests. + if ($request->isPrecognitive()) { + $action->validate($request->all(), $request->allFiles(), only: $this->precognitiveFields($request)); - $submission->data( - $fields->addValues($values)->process()->values() - ); + return response()->noContent(headers: ['Precognition-Success' => 'true']); + } - // 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); - } catch (ValidationException $e) { - $this->removeUploadedAssets($uploadedAssets); + $result = $action->submit($request->all(), $request->allFiles()); - return $this->formFailure($params, $e->errors(), $form->handle()); + $result->isFinalized() + ? $this->forgetPartialSubmission($form) + : $this->setPartialSubmission($form, $result->submission); + + return $this->formSuccess($params, $result); } catch (SilentFormFailureException $e) { - if (isset($uploadedAssets)) { - $this->removeUploadedAssets($uploadedAssets); - } + $result = new SubmissionResult(submission: $e->submission()); - return $this->formSuccess($params, $submission, true); + return $this->formSuccess($params, $result, silentFailure: true); + } catch (ValidationException $e) { + return $this->formFailure($params, $e->errors(), $form->handle()); } + } - $submission->finalize(); + private function params(Request $request): array + { + return collect($request->all()) + ->filter(fn ($value, string $key) => Str::startsWith($key, '_')) + ->all(); + } - return $this->formSuccess($params, $submission); + private function precognitiveFields(Request $request): ?array + { + if (! $request->headers->has('Precognition-Validate-Only')) { + return null; + } + + 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; } } @@ -85,12 +97,9 @@ private function validateContentType($request, $form) /** * The steps for a failed form submission. * - * @param array $params - * @param array $errors - * @param string $form * @return Response|RedirectResponse */ - private function formFailure($params, $errors, $form) + private function formFailure(array $params, array $errors, string $form) { $request = request(); @@ -121,14 +130,15 @@ private function formFailure($params, $errors, $form) * * Used for actual success and by honeypot. * - * @param array $params - * @param Submission $submission - * @param bool $silentFailure * @return Response */ - private function formSuccess($params, $submission, $silentFailure = false) + private function formSuccess(array $params, SubmissionResult $result, bool $silentFailure = false) { - $redirect = $this->formSuccessRedirect($params, $submission); + $submission = $result->submission; + + $redirect = $result->nextPage + ? Uri::of(url()->previous())->withQuery(['page' => $result->nextPage])->__toString() + : $this->formSuccessRedirect($params, $result->submission); if (request()->ajax() || request()->wantsJson()) { return response([ @@ -136,21 +146,24 @@ private function formSuccess($params, $submission, $silentFailure = false) 'submission_created' => ! $silentFailure, 'submission' => $submission->data(), 'redirect' => $redirect, + 'next_page' => $result->nextPage, ]); } - $response = $redirect ? redirect($redirect) : back(); + if (! $redirect) { + $redirect = Uri::of(url()->previous())->withoutQuery('page')->__toString(); + } - if (! \Statamic\Facades\URL::isExternal($redirect)) { + if (! $result->nextPage && ! \Statamic\Facades\URL::isExternal($redirect)) { session()->flash("form.{$submission->form()->handle()}.success", __('Submission successful.')); session()->flash("form.{$submission->form()->handle()}.submission_created", ! $silentFailure); session()->flash('submission', $submission); } - return $response; + return redirect($redirect); } - private function formSuccessRedirect($params, $submission) + private function formSuccessRedirect(array $params, $submission) { if ($redirect = Form::getSubmissionRedirect($submission)) { return $redirect; @@ -165,19 +178,25 @@ private function formSuccessRedirect($params, $submission) return $redirect; } - /** - * Remove any uploaded assets - * - * Triggered by a validation exception or silent failure - */ - private function removeUploadedAssets(array $assets) + private function setPartialSubmission($form, $submission): void + { + session()->put("form.{$form->handle()}.partial_submission", $submission->id()); + } + + private function getPartialSubmission($form): ?Submission + { + $id = session()->get("form.{$form->handle()}.partial_submission"); + $submission = $form->submission($id); + + if ($submission && ! $submission->isPartial()) { + return null; + } + + return $submission; + } + + private function forgetPartialSubmission($form): void { - collect($assets) - ->flatten() - ->each(function ($id) { - if ($asset = Asset::find($id)) { - $asset->delete(); - } - }); + session()->forget("form.{$form->handle()}.partial_submission"); } } 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/Jobs/DeletePartialFormSubmissions.php b/src/Jobs/DeletePartialFormSubmissions.php index 22681d73dcf..df116781cbe 100644 --- a/src/Jobs/DeletePartialFormSubmissions.php +++ b/src/Jobs/DeletePartialFormSubmissions.php @@ -6,6 +6,8 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Statamic\Contracts\Forms\Submission; +use Statamic\Facades\Asset; use Statamic\Facades\FormSubmission; class DeletePartialFormSubmissions implements ShouldQueue @@ -24,7 +26,25 @@ public function handle(): void ->where('partial', true) ->where('date', '<', $threshold) ->get() - ->each - ->delete(); + ->each(function (Submission $submission): void { + if (config('statamic.forms.garbage_collect_assets')) { + $this->garbageCollectAssets($submission); + } + + $submission->delete(); + }); + } + + private function garbageCollectAssets(Submission $submission): void + { + $submission->form()->blueprint()->fields()->all() + ->filter(fn ($field) => $field->fieldtype()->handle() === 'assets') + ->each(function ($field) use ($submission) { + $container = $field->get('container'); + + collect($submission->get($field->handle())) + ->filter() + ->each(fn ($path) => Asset::find("{$container}::{$path}")?->delete()); + }); } } diff --git a/tests/Forms/DeletePartialFormSubmissionsTest.php b/tests/Forms/DeletePartialFormSubmissionsTest.php index 45416c2662d..bccddacc7d2 100644 --- a/tests/Forms/DeletePartialFormSubmissionsTest.php +++ b/tests/Forms/DeletePartialFormSubmissionsTest.php @@ -3,7 +3,10 @@ namespace Tests\Forms; use Carbon\Carbon; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\AssetContainer; use Statamic\Facades\Form; use Statamic\Jobs\DeletePartialFormSubmissions; use Tests\PreventSavingStacheItemsToDisk; @@ -82,4 +85,68 @@ public function it_does_not_delete_anything_when_disabled() $this->assertNotNull($form->submission($partial->id())); } + + #[Test] + public function it_deletes_attached_assets_when_garbage_collection_is_enabled() + { + config([ + 'statamic.forms.delete_partial_submissions_after' => 7, + 'statamic.forms.garbage_collect_assets' => true, + ]); + + $form = $this->formWithUploadField(); + $partial = $this->partialWithAsset($form); + + Storage::disk('avatars')->assertExists('avatar.jpg'); + + (new DeletePartialFormSubmissions)->handle(); + + $this->assertNull($form->submission($partial->id())); + Storage::disk('avatars')->assertMissing('avatar.jpg'); + } + + #[Test] + public function it_leaves_attached_assets_when_garbage_collection_is_disabled() + { + config([ + 'statamic.forms.delete_partial_submissions_after' => 7, + 'statamic.forms.garbage_collect_assets' => false, + ]); + + $form = $this->formWithUploadField(); + $partial = $this->partialWithAsset($form); + + (new DeletePartialFormSubmissions)->handle(); + + // The partial is still deleted, but its asset is left untouched. + $this->assertNull($form->submission($partial->id())); + Storage::disk('avatars')->assertExists('avatar.jpg'); + } + + private function formWithUploadField() + { + Storage::fake('avatars'); + tap(AssetContainer::make('avatars')->disk('avatars'))->save(); + + return tap(Form::make('contact')->formFields([ + 'sections' => [ + ['fields' => [ + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']], + ]], + ], + ]))->save(); + } + + private function partialWithAsset($form) + { + $asset = tap(AssetContainer::find('avatars')->makeAsset('avatar.jpg')) + ->upload(UploadedFile::fake()->image('avatar.jpg')); + + Carbon::setTestNow('2025-06-01 12:00:00'); + $partial = tap($form->makeSubmission()->set('partial', true)->set('avatar', [$asset->path()]))->save(); + + Carbon::setTestNow('2025-06-30 12:00:00'); + + return $partial; + } } diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php index 15a6f90fe52..c2229205e4f 100644 --- a/tests/Forms/SubmissionTest.php +++ b/tests/Forms/SubmissionTest.php @@ -423,6 +423,27 @@ public function finalizing_a_submission_for_a_non_storing_form_still_dispatches_ $this->assertNull($form->submission($submission->id())); } + #[Test] + public function finalizing_a_submission_for_a_non_storing_form_deletes_it() + { + Bus::fake(); + Event::fake([SubmissionCreated::class, SubmissionFinalized::class, SubmissionDeleted::class]); + + $form = tap(Form::make('contact_us')->store(false))->save(); + + $submission = tap($form->makeSubmission()->set('partial', true))->save(); + $this->assertNotNull($form->submission($submission->id())); + + $submission->finalize(); + + $this->assertNull($form->submission($submission->id())); + + Event::assertDispatched(SubmissionCreated::class, 1); + Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(SendEmails::class, 1); + Event::assertNotDispatched(SubmissionDeleted::class); + } + #[Test] public function finalizing_is_idempotent() { diff --git a/tests/Forms/SubmitFormTest.php b/tests/Forms/SubmitFormTest.php new file mode 100644 index 00000000000..500e13358e1 --- /dev/null +++ b/tests/Forms/SubmitFormTest.php @@ -0,0 +1,579 @@ +andReturnFalse()->byDefault(); + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturnTrue()->byDefault(); + + $this->form = tap(Form::make('contact')->honeypot('winnie')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + '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)->page('main'); + } + + private function multiPageForm() + { + return tap(Form::make('signup')->honeypot('winnie')->formFields([ + 'pages' => [ + [ + 'id' => 'one', + 'sections' => [ + ['fields' => [['handle' => 'name', 'field' => ['type' => 'short_answer']]]], + ], + ], + [ + 'id' => 'two', + 'sections' => [ + ['fields' => [['handle' => 'email', 'field' => ['type' => 'email', 'validate' => 'required']]]], + ], + ], + [ + 'id' => 'three', + 'sections' => [ + ['fields' => [['handle' => 'message', 'field' => ['type' => 'long_answer']]]], + ], + ], + ], + ]))->save(); + } + + #[Test] + public function it_submits_a_form_successfully() + { + Event::fake([FormSubmitted::class]); + + $result = $this->action()->submit( + ['name' => 'Test User', 'email' => 'test@example.com', 'message' => 'Hello'], + ); + + $this->assertInstanceOf(SubmissionResult::class, $result); + $this->assertTrue($result->isFinalized()); + $this->assertNull($result->nextPage); + $this->assertEquals('Test User', $result->submission->get('name')); + $this->assertEquals('test@example.com', $result->submission->get('email')); + $this->assertEquals('Hello', $result->submission->get('message')); + + Event::assertDispatched(FormSubmitted::class, function ($event) { + return $event->submission->get('email') === 'test@example.com'; + }); + } + + #[Test] + public function it_saves_submission_when_store_is_enabled() + { + $this->assertEmpty($this->form->submissions()); + + $this->action()->submit(['email' => 'test@example.com']); + + $this->assertCount(1, $this->form->submissions()); + } + + #[Test] + public function it_finalizes_without_storing_when_store_is_disabled() + { + Bus::fake(); + Event::fake([SubmissionCreated::class, SubmissionFinalized::class]); + + $this->form->store(false); + $this->form->save(); + + $result = $this->action()->submit(['email' => 'test@example.com']); + + $this->assertTrue($result->isFinalized()); + $this->assertEmpty($this->form->submissions()); + Event::assertDispatched(SubmissionCreated::class); + Event::assertDispatched(SubmissionFinalized::class); + Bus::assertDispatched(SendEmails::class); + } + + #[Test] + public function validation_passes_with_valid_data() + { + $this->action()->validate(['email' => 'test@example.com']); + + $this->addToAssertionCount(1); + } + + #[Test] + public function it_throws_validation_exception_when_validation_fails() + { + $this->expectException(ValidationException::class); + + $this->action()->validate(['name' => 'Test']); // missing required email + } + + #[Test] + public function it_throws_validation_exception_with_field_errors() + { + try { + $this->action()->validate(['name' => 'Test']); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('email', $e->errors()); + } + } + + #[Test] + public function it_does_not_persist_a_submission_when_validation_fails() + { + $this->assertEmpty($this->form->submissions()); + + try { + $this->action()->submit(['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(['name' => 'Test'], only: ['name']); + + $this->addToAssertionCount(1); + } + + #[Test] + public function it_still_validates_scoped_fields() + { + $this->expectException(ValidationException::class); + + $this->action()->validate(['email' => 'not-an-email'], only: ['email']); + } + + #[Test] + public function it_throws_silent_failure_exception_when_honeypot_is_filled() + { + $this->expectException(SilentFormFailureException::class); + + $this->action()->submit( + ['email' => 'test@example.com', 'winnie' => 'the pooh'], + ); + } + + #[Test] + public function it_throws_silent_failure_exception_when_event_listener_returns_false() + { + Event::listen(FormSubmitted::class, fn () => false); + + try { + $this->action()->submit(['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(['email' => 'test@example.com']); + } + + #[Test] + public function it_uploads_files() + { + Storage::fake('avatars'); + AssetContainer::make('avatars')->disk('avatars')->save(); + + $form = $this->uploadForm(); + + app(SubmitForm::class) + ->form($form) + ->page('main') + ->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 = $this->uploadForm(honeypot: true); + + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->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 = $this->uploadForm(); + + Event::listen(FormSubmitted::class, fn () => false); + + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->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 = $this->uploadForm(); + + Event::listen(FormSubmitted::class, function () { + throw ValidationException::withMessages(['custom' => 'Error']); + }); + + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->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 it_returns_the_next_page_and_saves_a_partial_submission_when_submitting_a_non_final_page() + { + Bus::fake(); + Event::fake([FormSubmitted::class, SubmissionCreated::class, SubmissionFinalized::class]); + + $form = $this->multiPageForm(); + + $result = app(SubmitForm::class) + ->form($form) + ->page('one') + ->submit(['name' => 'Olaf']); + + $this->assertInstanceOf(SubmissionResult::class, $result); + $this->assertEquals('two', $result->nextPage); + $this->assertFalse($result->isFinalized()); + + $this->assertCount(1, $form->submissions()); + $this->assertTrue($result->submission->isPartial()); + $this->assertEquals('Olaf', $result->submission->get('name')); + + Event::assertDispatched(SubmissionCreated::class); + Event::assertNotDispatched(FormSubmitted::class); + Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(SendEmails::class); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_scopes_stored_values_to_the_current_page() + { + $form = $this->multiPageForm(); + + // The email belongs to a later page, so it shouldn't be stored when submitting page one. + $result = app(SubmitForm::class) + ->form($form) + ->page('one') + ->submit(['name' => 'Olaf', 'email' => 'olaf@example.com']); + + $this->assertEquals('Olaf', $result->submission->get('name')); + $this->assertNull($result->submission->get('email')); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_only_validates_the_current_pages_fields() + { + $form = $this->multiPageForm(); + + // Page one has no required fields, so the email being required on page two + // shouldn't cause a validation failure when submitting page one. + $result = app(SubmitForm::class) + ->form($form) + ->page('one') + ->submit(['name' => 'Olaf']); + + $this->assertEquals('two', $result->nextPage); + + // Page two requires the email. + try { + app(SubmitForm::class) + ->form($form) + ->page('two') + ->resume($result->submission) + ->submit([]); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('email', $e->errors()); + } + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_only_runs_the_honeypot_check_on_the_final_page() + { + $form = $this->multiPageForm(); + + // A filled honeypot on a non-final page is ignored; the partial submission saves normally. + $result = app(SubmitForm::class) + ->form($form) + ->page('one') + ->submit(['name' => 'Olaf', 'winnie' => 'the pooh']); + + $this->assertEquals('two', $result->nextPage); + $this->assertTrue($result->submission->isPartial()); + + // On the final page the honeypot triggers a silent failure. + try { + app(SubmitForm::class) + ->form($form) + ->page('three') + ->resume($result->submission) + ->submit(['message' => 'Hello', 'winnie' => 'the pooh']); + + $this->fail('Expected SilentFormFailureException was not thrown'); + } catch (SilentFormFailureException $e) { + $this->assertNotNull($e->submission()); + } + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_only_dispatches_the_form_submitted_event_on_the_final_page() + { + $form = $this->multiPageForm(); + + Event::listen(FormSubmitted::class, fn () => false); + + $result = app(SubmitForm::class) + ->form($form) + ->page('one') + ->submit(['name' => 'Olaf']); + + $this->assertTrue($result->submission->isPartial()); + $this->assertEquals('Olaf', $result->submission->get('name')); + + try { + app(SubmitForm::class) + ->form($form) + ->page('three') + ->resume($result->submission) + ->submit(['message' => 'Hello']); + + $this->fail('Expected SilentFormFailureException was not thrown'); + } catch (SilentFormFailureException $e) { + $this->assertNotNull($e->submission()); + } + + // The submission stays partial since completion was silently aborted. + $this->assertTrue($form->submission($result->submission->id())->isPartial()); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_resumes_a_partial_submission_on_a_later_page() + { + Bus::fake(); + Event::fake([FormSubmitted::class, SubmissionFinalized::class]); + + $form = $this->multiPageForm(); + + $first = app(SubmitForm::class) + ->form($form) + ->page('one') + ->submit(['name' => 'Olaf']); + + // Resuming continues the same partial submission on the next page rather than starting over. + $result = app(SubmitForm::class) + ->form($form) + ->page('two') + ->resume($first->submission) + ->submit(['email' => 'olaf@example.com']); + + $this->assertEquals($first->submission->id(), $result->submission->id()); + $this->assertCount(1, $form->submissions()); + $this->assertEquals('three', $result->nextPage); + + $this->assertFalse($result->isFinalized()); + $this->assertTrue($result->submission->isPartial()); + Event::assertNotDispatched(FormSubmitted::class); + Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(SendEmails::class); + + // Earlier-page values are preserved while the new page's values are merged in. + $stored = $form->submission($result->submission->id()); + $this->assertEquals('Olaf', $stored->get('name')); + $this->assertEquals('olaf@example.com', $stored->get('email')); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_finalizes_the_partial_submission_on_the_final_page() + { + Bus::fake(); + + $form = $this->multiPageForm(); + + // An in-progress partial submission that has already collected the earlier pages' values. + $partial = tap($form->makeSubmission()->data(['name' => 'Olaf', 'email' => 'olaf@example.com'])->asPartial())->save(); + + // Faked after seeding so the seeding's created event is out of scope. + Event::fake([SubmissionCreated::class, SubmissionFinalized::class]); + + $result = app(SubmitForm::class) + ->form($form) + ->page('three') + ->resume($partial) + ->submit(['message' => 'Hello']); + + // The partial submission is promoted to a finalized one rather than a new one being created. + $this->assertEquals($partial->id(), $result->submission->id()); + $this->assertCount(1, $form->submissions()); + $this->assertNull($result->nextPage); + $this->assertTrue($result->isFinalized()); + + $stored = $form->submission($result->submission->id()); + $this->assertFalse($stored->isPartial()); + + // Earlier pages' values are preserved while the final page's values are merged in. + $this->assertEquals('Olaf', $stored->get('name')); + $this->assertEquals('olaf@example.com', $stored->get('email')); + $this->assertEquals('Hello', $stored->get('message')); + + // Finalizing fires the completion events once; it doesn't re-create the submission. + Event::assertNotDispatched(SubmissionCreated::class); + Event::assertDispatched(SubmissionFinalized::class, 1); + Bus::assertDispatched(SendEmails::class, 1); + + $form->submissions()->each->delete(); + } + + private function uploadForm(bool $honeypot = false) + { + $form = Form::make('uploads'); + + if ($honeypot) { + $form->honeypot('winnie'); + } + + return tap($form->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + [ + 'fields' => [ + ['handle' => 'email', 'field' => ['type' => 'email']], + ['handle' => 'avatar', 'field' => ['type' => 'upload', 'store' => true, 'container' => 'avatars']], + ], + ], + ], + ], + ], + ]), fn ($f) => $f->save()); + } +} diff --git a/tests/Tags/Form/FormCreateTest.php b/tests/Tags/Form/FormCreateTest.php index 9c5a3856827..8a6e38729f1 100644 --- a/tests/Tags/Form/FormCreateTest.php +++ b/tests/Tags/Form/FormCreateTest.php @@ -2,13 +2,16 @@ namespace Tests\Tags\Form; +use Facades\Statamic\Console\Processes\Composer; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Storage; use Illuminate\Validation\ValidationException; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\AssetContainer; use Statamic\Facades\Form; +use Statamic\Forms\SendEmails; use Statamic\Statamic; class FormCreateTest extends FormTestCase @@ -725,6 +728,115 @@ public function it_dynamically_renders_field_with_fallback_to_default_partial() ]); } + #[Test] + public function it_dynamically_renders_pages_array() + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + + $this->createForm([ + 'pages' => [ + [ + 'id' => 'page_one', + 'display' => 'Page One', + 'instructions' => 'Page One Instructions', + 'sections' => [ + ['display' => 'Section A', 'fields' => [['handle' => 'name', 'field' => ['type' => 'text']]]], + ], + ], + [ + 'id' => 'page_two', + 'display' => 'Page Two', + 'previous_page_label' => 'Back', + 'sections' => [ + ['display' => 'Section B', 'fields' => [['handle' => 'email', 'field' => ['type' => 'text']]]], + ], + ], + ], + ], 'survey'); + + $output = $this->normalizeHtml($this->tag(<<<'EOT' +{{ form:survey }} + {{ pages }} +
{{ id }} - {{ display }}{{ if instructions }} ({{ instructions }}){{ /if }}{{ if previous_page_label }} - back:{{ previous_page_label }}{{ /if }} - button:{{ button_label }} - {{ sections }}[{{ display }}:{{ fields }}{{ handle }},{{ /fields }}]{{ /sections }}
+ {{ /pages }} +{{ /form:survey }} +EOT + )); + + // button_label should default to "Next", then "Submit" on the last page. + // The back button is only output when a previous_page_label is set. + $this->assertStringContainsString('
page_one - Page One (Page One Instructions) - button:Next - [Section A:name,]
', $output); + $this->assertStringContainsString('
page_two - Page Two - back:Back - button:Submit - [Section B:email,]
', $output); + } + + #[Test] + public function it_dynamically_renders_simplified_pages_array_when_forms_pro_is_not_installed() + { + // When forms-pro isn't installed, sections will be collapsed under a single page. + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false); + + $this->createForm([ + 'sections' => [ + ['display' => 'Section A', 'fields' => [['handle' => 'name', 'field' => ['type' => 'text']]]], + ['display' => 'Section B', 'fields' => [['handle' => 'email', 'field' => ['type' => 'text']]]], + ], + ], 'survey'); + + $output = $this->normalizeHtml($this->tag(<<<'EOT' +{{ form:survey }} + {{ pages }} +
{{ id }}{{ if previous_page_label }} - back:{{ previous_page_label }}{{ /if }} - button:{{ button_label }} - {{ sections }}[{{ display }}:{{ fields }}{{ handle }},{{ /fields }}]{{ /sections }}
+ {{ /pages }} +
{{ sections }}{{ display }},{{ /sections }}
+{{ /form:survey }} +EOT + )); + + $this->assertStringContainsString('
main - button:Submit - [Section A:name,][Section B:email,]
', $output); + } + + #[Test] + public function it_outputs_the_current_page() + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + + $this->createForm([ + 'pages' => [ + [ + 'id' => 'page_one', + 'display' => 'Page One', + 'instructions' => 'Page One Instructions', + 'sections' => [ + ['fields' => [['handle' => 'name', 'field' => ['type' => 'text']]]], + ], + ], + [ + 'id' => 'page_two', + 'display' => 'Page Two', + 'previous_page_label' => 'Back', + 'sections' => [ + ['fields' => [['handle' => 'email', 'field' => ['type' => 'text']]]], + ], + ], + ], + ], 'survey'); + + $template = <<<'EOT' +{{ form:survey }} +
{{ page:id }} - {{ page:display }}{{ if page:instructions }} ({{ page:instructions }}){{ /if }}{{ if page:previous_page_label }} - back:{{ page:previous_page_label }}{{ /if }} - button:{{ page:button_label }}
+{{ /form:survey }} +EOT; + + // Defaults to the first page; its button label is "Next" and there's no back button. + $output = $this->normalizeHtml($this->tag($template)); + $this->assertStringContainsString('
page_one - Page One (Page One Instructions) - button:Next
', $output); + + // Reflects the page query param; the last page's button label becomes "Submit". + request()->merge(['page' => 'page_two']); + $output = $this->normalizeHtml($this->tag($template)); + $this->assertStringContainsString('
page_two - Page Two - back:Back - button:Submit
', $output); + } + #[Test] public function it_dynamically_renders_sections_array() { @@ -923,6 +1035,37 @@ public function it_will_submit_form_and_render_success() $this->assertEquals(['Submission successful.'], $success[1]); } + #[Test] + public function it_only_outputs_the_success_message_after_the_final_page() + { + $this->createMultiPageForm(); + Form::find('survey')->save(); + + $template = <<<'EOT' +{{ form:survey }} +

{{ success }}

+{{ /form:survey }} +EOT; + + // Submitting a non-final page advances without outputting the success message. + $this + ->post('/!/forms/survey', ['_page' => 'page_one', 'name' => 'Olaf']) + ->assertSessionHasNoErrors(); + + preg_match_all('/

(.+)<\/p>/U', $this->tag($template), $success); + $this->assertEmpty($success[1]); + + // Submitting the final page outputs the success message. + $this + ->post('/!/forms/survey', ['_page' => 'page_two', 'email' => 'olaf@example.com']) + ->assertSessionHasNoErrors(); + + preg_match_all('/

(.+)<\/p>/U', $this->tag($template), $success); + $this->assertEquals(['Submission successful.'], $success[1]); + + Form::find('survey')->submissions()->each->delete(); + } + #[Test] public function it_will_submit_form_and_follow_custom_redirect_with_success() { @@ -1269,6 +1412,128 @@ public function it_removes_any_uploaded_assets_when_a_listener_throws_a_validati Storage::disk('avatars')->assertMissing('avatar.jpg'); } + #[Test] + public function it_renders_the_first_pages_sections_by_default() + { + $this->createMultiPageForm(); + + $output = $this->normalizeHtml($this->tag(<<<'EOT' +{{ form:survey }} + {{ sections }}

{{ display }}:{{ fields }}{{ handle }},{{ /fields }}
{{ /sections }} +{{ /form:survey }} +EOT + )); + + // Only the first page's section is rendered. + $this->assertStringContainsString('
Section A:name,
', $output); + $this->assertStringNotContainsString('Section B', $output); + $this->assertStringNotContainsString('email', $output); + } + + #[Test] + public function it_renders_a_specific_pages_sections_based_on_the_page_query_param() + { + $this->createMultiPageForm(); + + request()->merge(['page' => 'page_two']); + + $output = $this->normalizeHtml($this->tag(<<<'EOT' +{{ form:survey }} + {{ sections }}
{{ display }}:{{ fields }}{{ handle }},{{ /fields }}
{{ /sections }} +{{ /form:survey }} +EOT + )); + + $this->assertStringContainsString('
Section B:email,
', $output); + $this->assertStringNotContainsString('Section A', $output); + $this->assertStringNotContainsString('>name,', $output); + } + + #[Test] + public function it_falls_back_to_the_first_page_when_the_page_query_param_is_invalid() + { + $this->createMultiPageForm(); + + request()->merge(['page' => 'page_nope']); + + $output = $this->normalizeHtml($this->tag(<<<'EOT' +{{ form:survey }} + {{ sections }}
{{ display }}:{{ fields }}{{ handle }},{{ /fields }}
{{ /sections }} +{{ /form:survey }} +EOT + )); + + $this->assertStringContainsString('
Section A:name,
', $output); + $this->assertStringNotContainsString('Section B', $output); + } + + #[Test] + public function it_outputs_a_hidden_page_input_for_multi_page_forms() + { + $this->createMultiPageForm(); + + // The current page defaults to the first. + $output = $this->tag('{{ form:survey }}{{ /form:survey }}'); + $this->assertStringContainsString('', $output); + + // It reflects the page query param. + request()->merge(['page' => 'page_two']); + $output = $this->tag('{{ form:survey }}{{ /form:survey }}'); + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_does_not_output_a_hidden_page_input_for_single_page_forms() + { + // The default contact form (forms-pro disabled) is a single page. + $output = $this->tag('{{ form:contact }}{{ /form:contact }}'); + + $this->assertStringNotContainsString('name="_page"', $output); + } + + #[Test] + public function it_populates_fields_from_the_session_partial_submission() + { + $this->createMultiPageForm(); + + $form = Form::find('survey'); + $form->save(); + $submission = tap($form->makeSubmission()->data(['name' => 'Olaf', 'email' => 'olaf@example.com'])->asPartial())->save(); + + session()->put('form.survey.partial_submission', $submission->id()); + + // The first page's field is populated with the stored value. + $pageOne = $this->normalizeHtml($this->tag('{{ form:survey }}{{ fields }}{{ handle }}={{ value }},{{ /fields }}{{ /form:survey }}')); + $this->assertStringContainsString('name=Olaf,', $pageOne); + + // Navigating to the second page populates its field too. + request()->merge(['page' => 'page_two']); + $pageTwo = $this->normalizeHtml($this->tag('{{ form:survey }}{{ fields }}{{ handle }}={{ value }},{{ /fields }}{{ /form:survey }}')); + $this->assertStringContainsString('email=olaf@example.com,', $pageTwo); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_does_not_populate_fields_from_a_finalized_submission() + { + $this->createMultiPageForm(); + + $form = Form::find('survey'); + $form->save(); + $submission = tap($form->makeSubmission()->data(['name' => 'Olaf'])->asPartial())->save(); + $submission->finalize(); + + session()->put('form.survey.partial_submission', $submission->id()); + + $output = $this->normalizeHtml($this->tag('{{ form:survey }}{{ fields }}{{ handle }}={{ value }},{{ /fields }}{{ /form:survey }}')); + + // The submission is no longer partial, so its values aren't loaded back in. + $this->assertStringContainsString('name=,', $output); + + $form->submissions()->each->delete(); + } + #[Test] public function it_renders_exceptions_thrown_during_json_requests_as_standard_laravel_errors() { @@ -1314,6 +1579,24 @@ public function it_renders_exceptions_thrown_during_xml_http_requests_in_statami $this->assertSame($json['error'], ['some' => 'error']); } + #[Test] + public function a_precognitive_success_does_not_persist_a_submission() + { + Bus::fake(); + + $this->assertEmpty(Form::find('contact')->submissions()); + + $this + ->withPrecognition() + ->withHeaders(['Precognition-Validate-Only' => 'email']) + ->postJson('/!/forms/contact', ['email' => 'test@example.com']) + ->assertNoContent() + ->assertHeader('Precognition-Success', 'true'); + + $this->assertEmpty(Form::find('contact')->submissions()); + Bus::assertNotDispatched(SendEmails::class); + } + #[Test] public function it_adds_appended_config_fields() { diff --git a/tests/Tags/Form/FormTestCase.php b/tests/Tags/Form/FormTestCase.php index 203055d2946..974598e4b4c 100644 --- a/tests/Tags/Form/FormTestCase.php +++ b/tests/Tags/Form/FormTestCase.php @@ -2,6 +2,7 @@ namespace Tests\Tags\Form; +use Facades\Statamic\Console\Processes\Composer; use Illuminate\Support\Facades\Blade; use Statamic\Facades\Form; use Statamic\Facades\Parse; @@ -49,6 +50,8 @@ public function setUp(): void { parent::setUp(); + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(false)->byDefault(); + $this->createForm(); $this->clearSubmissions(); } @@ -96,6 +99,28 @@ protected function createForm($fieldContents = null, $handle = null) Form::makePartial(); } + protected function createMultiPageForm($handle = 'survey') + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + + $this->createForm([ + 'pages' => [ + [ + 'id' => 'page_one', + 'sections' => [ + ['display' => 'Section A', 'fields' => [['handle' => 'name', 'field' => ['type' => 'text']]]], + ], + ], + [ + 'id' => 'page_two', + 'sections' => [ + ['display' => 'Section B', 'fields' => [['handle' => 'email', 'field' => ['type' => 'text']]]], + ], + ], + ], + ], $handle); + } + protected function assertFieldRendersHtml($expectedHtmlParts, $fieldConfig, $oldData = [], $extraParams = []) { $handle = str_shuffle('nobodymesseswiththehoff');