diff --git a/config/forms.php b/config/forms.php index f411498097e..03617f1b0df 100644 --- a/config/forms.php +++ b/config/forms.php @@ -40,13 +40,16 @@ | Partial Submissions |-------------------------------------------------------------------------- | - | Partial submissions are automatically deleted after a set number - | of days. Set this to null to prevent their automatic deletion. + | Partial submissions are automatically deleted after a set number of days. + | Set this to null to prevent their automatic deletion. You may also enable + | garbage collection to delete related assets at the same time. | */ '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..1ec9c20bfb5 100644 --- a/src/Forms/JsDrivers/AbstractJsDriver.php +++ b/src/Forms/JsDrivers/AbstractJsDriver.php @@ -3,6 +3,7 @@ namespace Statamic\Forms\JsDrivers; use Illuminate\Support\Collection; +use Statamic\Contracts\Forms\Submission; use Statamic\Forms\Form; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -76,6 +77,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. * @@ -128,16 +141,34 @@ protected function validateRenderMethodReturnsHtml() */ protected function getInitialFormData(): array { + $values = []; + + if ($partialSubmission = $this->getPartialSubmission()) { + $values = $partialSubmission->data()->all(); + } + return $this->form ->blueprint() ->fields() - ->addValues(old() ?? []) + ->addValues([...$values, ...old()]) ->preProcess() ->values() ->when($this->form->honeypot(), fn ($fields, $honeypot) => $fields->merge([$honeypot => null])) ->all(); } + 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; + } + /** * Recursively get flattened fields collection. */ 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/Logic/PageLogic.php b/src/Forms/Logic/PageLogic.php new file mode 100644 index 00000000000..ed3899526a1 --- /dev/null +++ b/src/Forms/Logic/PageLogic.php @@ -0,0 +1,97 @@ +buildPath($data, null); + } + + public function pathTo(array $data, string $page): array + { + return $this->buildPath($data, $page); + } + + private function buildPath(array $data, ?string $until): array + { + $path = []; + $pageId = Arr::get($this->pages()->first(), 'id'); + + // The "not in path" guard stops a cyclical rule from looping forever. + while ($pageId !== null && ! in_array($pageId, $path, true)) { + $path[] = $pageId; + + if ($pageId === $until) { + break; + } + + $pageId = $this->nextPage($pageId, $data); + } + + return $path; + } + + public function nextPage(string $currentPageId, array $data): ?string + { + $pages = $this->pages(); + $currentIndex = $pages->search(fn (array $page): bool => $page['id'] === $currentPageId); + + if ($currentIndex === false) { + return null; + } + + if ($destination = $this->matchingDestination($pages->get($currentIndex), $data)) { + return $destination; + } + + $nextPage = $pages->get($currentIndex + 1); + + return $nextPage ? $nextPage['id'] : null; + } + + public function isFinalPage(string $currentPageId, array $data): bool + { + return $this->nextPage($currentPageId, $data) === null; + } + + private function matchingDestination(array $page, array $data): ?string + { + $evaluator = new RuleEvaluator; + + foreach ($page['rules'] ?? [] as $rule) { + $destination = $rule['destination'] ?? null; + + if (! $destination || ! $this->pageExists($destination)) { + continue; + } + + if ($evaluator->passes($rule['conditions'] ?? [], $data)) { + return $destination; + } + } + + return null; + } + + private function pageExists(string $id): bool + { + return $this->pages()->contains(fn (array $page): bool => $page['id'] === $id); + } + + private function pages(): Collection + { + return $this->pages ??= $this->form->formFields()->pages(); + } +} diff --git a/src/Forms/Logic/RuleEvaluator.php b/src/Forms/Logic/RuleEvaluator.php new file mode 100644 index 00000000000..4d62c165ff7 --- /dev/null +++ b/src/Forms/Logic/RuleEvaluator.php @@ -0,0 +1,193 @@ +', '>=', '<', '<=']; + + /** + * Conditions are grouped by their `join`: a new "or" group begins at the + * first condition and at every condition joined with "or". A group passes + * when all of its conditions pass, and the rule passes when any group does. + * In other words, "and" binds tighter than "or". + */ + public function passes(array $conditions, array $data): bool + { + if (empty($conditions)) { + return false; + } + + return collect($this->groups($conditions)) + ->contains(fn (array $group): bool => $this->groupPasses($group, $data)); + } + + private function groups(array $conditions): array + { + $groups = []; + $current = []; + + foreach ($conditions as $index => $condition) { + $join = $condition['join'] ?? 'and'; + + if ($index > 0 && $join === 'or') { + $groups[] = $current; + $current = []; + } + + $current[] = $condition; + } + + $groups[] = $current; + + return $groups; + } + + private function groupPasses(array $group, array $data): bool + { + return collect($group)->every(fn (array $condition): bool => $this->conditionPasses($condition, $data)); + } + + private function conditionPasses(array $condition, array $data): bool + { + $operator = $this->normalizeOperator($condition['operator'] ?? 'equals'); + $lhs = $this->prepareLhs(Arr::get($data, $condition['field']), $operator); + $rhs = $this->prepareRhs($condition['value'] ?? null, $operator); + + return match ($operator) { + 'includes' => $this->includes($lhs, $rhs), + 'includes_any' => $this->includesAny($lhs, $rhs), + default => $this->compare($lhs, $operator, $rhs), + }; + } + + private function normalizeOperator(string $operator): string + { + return match ($operator) { + '', 'is', 'equals' => '==', + 'isnt', 'not' => '!=', + 'contains', 'includes' => 'includes', + 'contains_any', 'includes_any' => 'includes_any', + default => $operator, + }; + } + + private function prepareLhs(mixed $lhs, string $operator): mixed + { + if (in_array($operator, self::NUMBER_COMPARISONS)) { + return $this->toNumber($lhs); + } + + if ($operator === 'includes' && ! is_array($lhs)) { + return $lhs === null || $lhs === '' ? '' : (string) $lhs; + } + + if (is_string($lhs)) { + $lhs = trim($lhs); + + return $lhs === '' ? null : $lhs; + } + + return $lhs; + } + + private function prepareRhs(mixed $rhs, string $operator): mixed + { + $rhs = match ($rhs) { + 'null' => null, + 'true' => true, + 'false' => false, + default => $rhs, + }; + + if (in_array($operator, self::NUMBER_COMPARISONS)) { + return $this->toNumber($rhs); + } + + if ($rhs === 'empty' || $operator === 'includes' || $operator === 'includes_any') { + return $rhs; + } + + return is_string($rhs) ? trim($rhs) : $rhs; + } + + private function compare(mixed $lhs, string $operator, mixed $rhs): bool + { + if ($rhs === 'empty') { + $lhs = $this->isEmpty($lhs); + $rhs = true; + $operator = '=='; + } + + if (is_array($lhs)) { + return false; + } + + return match ($operator) { + '==' => $lhs == $rhs, + '!=' => $lhs != $rhs, + '===' => $lhs === $rhs, + '!==' => $lhs !== $rhs, + '>' => $lhs > $rhs, + '>=' => $lhs >= $rhs, + '<' => $lhs < $rhs, + '<=' => $lhs <= $rhs, + default => false, + }; + } + + private function includes(mixed $lhs, mixed $rhs): bool + { + if (is_array($lhs)) { + return in_array($rhs, $lhs); + } + + return str_contains((string) $lhs, (string) $rhs); + } + + private function includesAny(mixed $lhs, mixed $rhs): bool + { + $values = collect(explode(',', (string) $rhs)) + ->map(fn (string $value): string => trim($value)) + ->reject(fn (string $value): bool => $value === '') + ->all(); + + if (is_array($lhs)) { + return count(array_intersect($lhs, $values)) > 0; + } + + return collect($values)->contains(fn (string $value): bool => str_contains((string) $lhs, $value)); + } + + private function isEmpty(mixed $value): bool + { + return match (true) { + $value === null => true, + is_array($value) => count($value) === 0, + is_string($value) => $value === '', + default => false, + }; + } + + private function toNumber(mixed $value): int|float + { + if (is_numeric($value)) { + return $value + 0; + } + + return match ($value) { + null, '', false => 0, + true => 1, + default => NAN, + }; + } +} diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php index ed42b952e36..7bef0a59fbc 100644 --- a/src/Forms/Submission.php +++ b/src/Forms/Submission.php @@ -255,10 +255,14 @@ public function finalize() if ($this->form()->store()) { $this->save(); } else { - // When stored, save() dispatches the created event. We'll also fire it - // here when submissions aren't stored so developers may continue to - // listen and modify the submission as needed. - SubmissionCreated::dispatch($this); + // Fire the created event here for submissions that were never saved. + // The event might have been dispatched when the submission persisted, + // so we don't want to fire it again. + if (is_null($this->form()->submission($this->id()))) { + 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..3007bd9dba4 --- /dev/null +++ b/src/Forms/SubmitForm.php @@ -0,0 +1,233 @@ +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 + { + $nextPage = null; + $uploadedAssets = []; + $files = $this->normalizeFiles($files); + $values = array_merge($data, $files); + + $this->validate($data, $files); + + $this->submission = $this->submission ?? $this->form->makeSubmission()->asPartial()->site($this->site()); + + try { + $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); + + $nextPage = $this->resolveNextPage(); + + if ($this->shouldFinalize($nextPage) && ! $this->hasCompletedEveryPage()) { + $nextPage = Arr::get($this->form->formFields()->pages()->first(), 'id'); + } + + if ($this->shouldFinalize($nextPage)) { + throw_if(Arr::get($values, $this->form->honeypot()), new SilentFormFailureException($this->submission)); + throw_if(FormSubmitted::dispatch($this->submission) === false, new SilentFormFailureException($this->submission)); + } + } catch (ValidationException|SilentFormFailureException $e) { + $this->removeUploadedAssets($uploadedAssets); + + throw $e; + } + + $this->shouldFinalize($nextPage) ? $this->submission->finalize() : $this->submission->save(); + + return new SubmissionResult($this->submission, $nextPage); + } + + /** + * 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 resolveNextPage(): ?string + { + if (! $this->form->hasMultiplePages()) { + return null; + } + + return (new PageLogic($this->form))->nextPage($this->page, $this->submission->data()->all()); + } + + private function shouldFinalize(?string $nextPage): bool + { + return ! $this->form->hasMultiplePages() || ! $nextPage; + } + + private function hasCompletedEveryPage(): bool + { + if (! $this->form->hasMultiplePages()) { + return true; + } + + $data = $this->submission->data()->all(); + + $pageHandles = collect((new PageLogic($this->form))->path($data)) + ->flatMap(fn (string $id): array => $this->fieldHandles($id)); + + return $this->form->blueprint()->fields()->all() + ->filter(fn ($field): bool => $pageHandles->contains($field->handle()) && $field->isRequired()) + ->every(fn ($field): bool => $this->fieldHasValue($data, $field->handle())); + } + + private function fieldHasValue(array $data, string $handle): bool + { + $value = Arr::get($data, $handle); + + return $value !== null && $value !== '' && $value !== []; + } + + 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 => in_array($field->fieldtype()->handle(), ['assets', 'files'])) + ->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) { + // A handle also covers its nested/array attributes, e.g. "document" matches "document.0". + $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..a8b775fa248 100644 --- a/src/Forms/Tags.php +++ b/src/Forms/Tags.php @@ -5,13 +5,17 @@ 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\Forms\Logic\PageLogic; use Statamic\Support\Arr; use Statamic\Support\Html; use Statamic\Support\Str; @@ -19,6 +23,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 +82,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 +136,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 +304,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 +312,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 +516,67 @@ 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'); + } + + private function previousPage(): ?string + { + $pages = $this->form()->formFields()->pages(); + $currentPageId = Arr::get($this->currentPage(), 'id'); + $firstPageId = Arr::get($pages->first(), 'id'); + + if ($currentPageId === $firstPageId) { + return null; + } + + $data = $this->getPartialSubmission()?->data()->all() ?? []; + $path = (new PageLogic($this->form()))->pathTo($data, $currentPageId); + $currentIndex = array_search($currentPageId, $path, true); + + // The current page comes from the ?page= query param, so it may point to a + // page the logic wouldn't actually route to and won't be on the path. + if ($currentIndex === false) { + return $firstPageId; + } + + return $path[$currentIndex - 1] ?? null; + } + + private function previousPageUrl(): ?string + { + if (! $previousPage = $this->previousPage()) { + return null; + } + + return Uri::of(url()->current())->withQuery(['page' => $previousPage])->__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..18c6234d579 100644 --- a/src/Http/Controllers/FormController.php +++ b/src/Http/Controllers/FormController.php @@ -2,18 +2,19 @@ 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\Facades\URL; 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 +22,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 +98,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(); @@ -109,7 +119,7 @@ private function formFailure($params, $errors, $form) $redirect = Arr::get($params, '_error_redirect'); - $response = $redirect && ! \Statamic\Facades\URL::isExternalToApplication($redirect) + $response = $redirect && ! URL::isExternalToApplication($redirect) ? redirect($redirect) : back(); @@ -121,14 +131,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($this->previousUrl())->withQuery(['page' => $result->nextPage])->__toString() + : $this->formSuccessRedirect($params, $result->submission); if (request()->ajax() || request()->wantsJson()) { return response([ @@ -136,21 +147,31 @@ 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($this->previousUrl())->withoutQuery('page')->__toString(); + } - if (! \Statamic\Facades\URL::isExternal($redirect)) { + if (! $result->nextPage && ! 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 previousUrl(): string + { + $previous = url()->previous(); + + return URL::isExternalToApplication($previous) ? url('/') : $previous; } - private function formSuccessRedirect($params, $submission) + private function formSuccessRedirect(array $params, $submission) { if ($redirect = Form::getSubmissionRedirect($submission)) { return $redirect; @@ -158,26 +179,32 @@ private function formSuccessRedirect($params, $submission) $redirect = Arr::get($params, '_redirect'); - if ($redirect && \Statamic\Facades\URL::isExternalToApplication($redirect)) { + if ($redirect && URL::isExternalToApplication($redirect)) { return null; } 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/Logic/PageLogicTest.php b/tests/Forms/Logic/PageLogicTest.php new file mode 100644 index 00000000000..34e4be305f1 --- /dev/null +++ b/tests/Forms/Logic/PageLogicTest.php @@ -0,0 +1,149 @@ +andReturnFalse()->byDefault(); + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturnTrue()->byDefault(); + } + + private function form(array $pages) + { + return Form::make('survey')->formFields(['pages' => $pages]); + } + + private function page(string $id, array $rules = []) + { + return [ + 'id' => $id, + 'rules' => $rules, + 'sections' => [['fields' => [['handle' => $id.'_field', 'field' => ['type' => 'short_answer']]]]], + ]; + } + + private function rule(string $destination, array $conditions) + { + return ['conditions' => $conditions, 'destination' => $destination]; + } + + private function condition(string $field, string $operator, mixed $value) + { + return ['field' => $field, 'operator' => $operator, 'value' => $value]; + } + + #[Test] + public function it_builds_the_full_path_through_the_form() + { + $form = $this->form([ + $this->page('one', [$this->rule('three', [$this->condition('colour', 'equals', 'blue')])]), + $this->page('two'), + $this->page('three'), + ]); + + // No rule matches, so the path runs through every page sequentially. + $this->assertEquals(['one', 'two', 'three'], (new PageLogic($form))->path(['colour' => 'red'])); + + // The matching rule skips page two. + $this->assertEquals(['one', 'three'], (new PageLogic($form))->path(['colour' => 'blue'])); + } + + #[Test] + public function it_builds_the_path_up_to_a_given_page() + { + $form = $this->form([ + $this->page('one'), + $this->page('two'), + $this->page('three'), + ]); + + $this->assertEquals(['one', 'two'], (new PageLogic($form))->pathTo([], 'two')); + } + + #[Test] + public function it_advances_to_the_next_sequential_page_when_no_rules_match() + { + $form = $this->form([ + $this->page('one', [$this->rule('three', [$this->condition('colour', 'equals', 'blue')])]), + $this->page('two'), + $this->page('three'), + ]); + + $this->assertEquals('two', (new PageLogic($form))->nextPage('one', ['colour' => 'red'])); + } + + #[Test] + public function it_routes_to_a_matching_rules_destination() + { + $form = $this->form([ + $this->page('one', [$this->rule('three', [$this->condition('colour', 'equals', 'blue')])]), + $this->page('two'), + $this->page('three'), + ]); + + $this->assertEquals('three', (new PageLogic($form))->nextPage('one', ['colour' => 'blue'])); + } + + #[Test] + public function the_first_matching_rule_wins() + { + $form = $this->form([ + $this->page('one', [ + $this->rule('two', [$this->condition('colour', 'equals', 'blue')]), + $this->rule('three', [$this->condition('colour', 'equals', 'blue')]), + ]), + $this->page('two'), + $this->page('three'), + ]); + + $this->assertEquals('two', (new PageLogic($form))->nextPage('one', ['colour' => 'blue'])); + } + + #[Test] + public function the_final_page_has_no_next_page() + { + $form = $this->form([ + $this->page('one'), + $this->page('two'), + ]); + + $this->assertNull((new PageLogic($form))->nextPage('two', [])); + $this->assertTrue((new PageLogic($form))->isFinalPage('two', [])); + $this->assertFalse((new PageLogic($form))->isFinalPage('one', [])); + } + + #[Test] + public function a_rule_pointing_at_a_deleted_page_is_skipped() + { + $form = $this->form([ + $this->page('one', [$this->rule('gone', [$this->condition('colour', 'equals', 'blue')])]), + $this->page('two'), + ]); + + $this->assertEquals('two', (new PageLogic($form))->nextPage('one', ['colour' => 'blue'])); + } + + #[Test] + public function an_unknown_current_page_has_no_next_page() + { + $form = $this->form([ + $this->page('one'), + $this->page('two'), + ]); + + $this->assertNull((new PageLogic($form))->nextPage('nonexistent', [])); + } +} diff --git a/tests/Forms/Logic/RuleEvaluatorTest.php b/tests/Forms/Logic/RuleEvaluatorTest.php new file mode 100644 index 00000000000..028b782ea92 --- /dev/null +++ b/tests/Forms/Logic/RuleEvaluatorTest.php @@ -0,0 +1,133 @@ + $join, + 'field' => $field, + 'operator' => $operator, + 'value' => $value, + ], fn ($value) => $value !== null); + } + + #[Test] + public function an_empty_set_of_conditions_does_not_pass() + { + $this->assertFalse((new RuleEvaluator)->passes([], ['field' => 'whatever'])); + } + + #[Test] + #[DataProvider('operatorProvider')] + public function it_evaluates_operators(string $operator, mixed $value, mixed $fieldValue, bool $expected) + { + $passes = (new RuleEvaluator)->passes( + [$this->condition($operator, $value)], + ['field' => $fieldValue], + ); + + $this->assertEquals($expected, $passes); + } + + public static function operatorProvider(): array + { + return [ + 'equals pass' => ['equals', 'blue', 'blue', true], + 'equals fail' => ['equals', 'blue', 'red', false], + 'equals trims whitespace' => ['equals', 'blue', ' blue ', true], + 'not pass' => ['not', 'blue', 'red', true], + 'not fail' => ['not', 'blue', 'blue', false], + + 'contains string pass' => ['contains', 'ell', 'hello', true], + 'contains string fail' => ['contains', 'xyz', 'hello', false], + 'contains array pass' => ['contains', 'b', ['a', 'b', 'c'], true], + 'contains array fail' => ['contains', 'z', ['a', 'b', 'c'], false], + + 'contains_any string pass' => ['contains_any', 'red, blue', 'i like blue', true], + 'contains_any string fail' => ['contains_any', 'red, green', 'i like blue', false], + 'contains_any array pass' => ['contains_any', 'blue, green', ['red', 'green'], true], + 'contains_any array fail' => ['contains_any', 'blue, yellow', ['red', 'green'], false], + + 'strict equals pass' => ['===', '5', '5', true], + 'strict not equals pass' => ['!==', '5', '6', true], + + 'greater than pass' => ['>', '5', '10', true], + 'greater than fail' => ['>', '5', '3', false], + 'greater than or equal pass' => ['>=', '5', '5', true], + 'less than pass' => ['<', '5', '3', true], + 'less than or equal pass' => ['<=', '5', '5', true], + 'numeric comparison ignores non-numeric' => ['>', '5', 'banana', false], + + 'null literal pass' => ['equals', 'null', null, true], + 'true literal pass' => ['equals', 'true', true, true], + 'false literal pass' => ['equals', 'false', false, true], + + 'empty pass when null' => ['equals', 'empty', null, true], + 'empty pass when empty string' => ['equals', 'empty', '', true], + 'empty fail when present' => ['equals', 'empty', 'hello', false], + + 'empty string field is treated as null' => ['equals', 'blue', '', false], + 'missing field does not pass equals' => ['equals', 'blue', null, false], + ]; + } + + #[Test] + public function and_joined_conditions_all_have_to_pass() + { + $evaluator = new RuleEvaluator; + + $conditions = [ + $this->condition('equals', 'a', field: 'one'), + $this->condition('equals', 'b', field: 'two', join: 'and'), + ]; + + $this->assertTrue($evaluator->passes($conditions, ['one' => 'a', 'two' => 'b'])); + $this->assertFalse($evaluator->passes($conditions, ['one' => 'a', 'two' => 'x'])); + } + + #[Test] + public function or_joined_conditions_only_need_one_to_pass() + { + $evaluator = new RuleEvaluator; + + $conditions = [ + $this->condition('equals', 'a', field: 'one'), + $this->condition('equals', 'b', field: 'two', join: 'or'), + ]; + + $this->assertTrue($evaluator->passes($conditions, ['one' => 'a', 'two' => 'x'])); + $this->assertTrue($evaluator->passes($conditions, ['one' => 'x', 'two' => 'b'])); + $this->assertFalse($evaluator->passes($conditions, ['one' => 'x', 'two' => 'x'])); + } + + #[Test] + public function and_binds_tighter_than_or() + { + // a OR b AND c => a OR (b AND c) + $conditions = [ + $this->condition('equals', 'a', field: 'one'), + $this->condition('equals', 'b', field: 'two', join: 'or'), + $this->condition('equals', 'c', field: 'three', join: 'and'), + ]; + + $evaluator = new RuleEvaluator; + + // a true, the (b AND c) group false. Grouping => passes. A naive + // left-to-right fold ((a OR b) AND c) would fail. + $this->assertTrue($evaluator->passes($conditions, ['one' => 'a', 'two' => 'x', 'three' => 'x'])); + + // a false, b true but c false => (b AND c) false => whole thing false. + $this->assertFalse($evaluator->passes($conditions, ['one' => 'x', 'two' => 'b', 'three' => 'x'])); + + // a false, b and c true => (b AND c) true => passes. + $this->assertTrue($evaluator->passes($conditions, ['one' => 'x', 'two' => 'b', 'three' => 'c'])); + } +} 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..0b7d3cc259f --- /dev/null +++ b/tests/Forms/SubmitFormTest.php @@ -0,0 +1,720 @@ +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(); + } + + private function multiPageFormWithLogic() + { + return tap(Form::make('signup')->formFields([ + 'pages' => [ + [ + 'id' => 'one', + 'rules' => [[ + 'conditions' => [['field' => 'name', 'operator' => 'equals', 'value' => 'skip']], + 'destination' => 'three', + ]], + 'sections' => [['fields' => [['handle' => 'name', 'field' => ['type' => 'short_answer']]]]], + ], + [ + 'id' => 'two', + 'sections' => [['fields' => [['handle' => 'email', 'field' => ['type' => 'email']]]]], + ], + [ + '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_validates_the_extension_of_uploaded_files() + { + Storage::fake('local'); + + // store: false makes this a temporary "files" upload rather than a stored asset. + $form = tap(Form::make('uploads')->formFields([ + 'pages' => [ + [ + 'id' => 'main', + 'sections' => [ + ['fields' => [['handle' => 'document', 'field' => ['type' => 'upload', 'store' => false]]]], + ], + ], + ], + ]))->save(); + + // A disallowed extension is rejected. + try { + app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit(data: [], files: ['document' => [UploadedFile::fake()->create('virus.php', 10)]]); + + $this->fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $this->assertArrayHasKey('document.0', $e->errors()); + } + + // An allowed extension passes. + $result = app(SubmitForm::class) + ->form($form) + ->page('main') + ->submit(data: [], files: ['document' => [UploadedFile::fake()->create('resume.pdf', 10)]]); + + $this->assertTrue($result->isFinalized()); + + $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_takes_page_logic_into_account_when_resolving_the_next_page() + { + $form = $this->multiPageFormWithLogic(); + + // A matching submission follows the rule past page two, straight to page three. + $jumped = app(SubmitForm::class)->form($form)->page('one')->submit(['name' => 'skip']); + $this->assertEquals('three', $jumped->nextPage); + + // A non-matching submission advances to the next sequential page. + $advanced = app(SubmitForm::class)->form($form)->page('one')->submit(['name' => 'Olaf']); + $this->assertEquals('two', $advanced->nextPage); + + $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 non-final pages 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()); + + $result = app(SubmitForm::class) + ->form($form) + ->page('two') + ->resume($result->submission) + ->submit(['email' => 'olaf@example.com']); + + // 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')); + + $result = app(SubmitForm::class) + ->form($form) + ->page('two') + ->resume($result->submission) + ->submit(['email' => 'olaf@example.com']); + + 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(); + } + + #[Test] + public function it_returns_to_the_first_page_when_finalizing_without_completing_every_page() + { + Bus::fake(); + Event::fake([FormSubmitted::class, SubmissionFinalized::class]); + + $form = $this->multiPageForm(); + + // Jump straight to the final page without completing the earlier pages. + $result = app(SubmitForm::class) + ->form($form) + ->page('three') + ->submit(['message' => 'Hello']); + + // Rather than finalizing, the user is sent back to the first page to fill the form in properly. + $this->assertEquals('one', $result->nextPage); + $this->assertFalse($result->isFinalized()); + $this->assertTrue($result->submission->isPartial()); + + Event::assertNotDispatched(FormSubmitted::class); + Event::assertNotDispatched(SubmissionFinalized::class); + Bus::assertNotDispatched(SendEmails::class); + + $form->submissions()->each->delete(); + } + + #[Test] + public function it_finalizes_when_page_logic_legitimately_skips_a_page() + { + $form = $this->multiPageFormWithLogic(); + + // name=skip satisfies page one's rule, routing straight to page three and skipping page two. + $first = app(SubmitForm::class)->form($form)->page('one')->submit(['name' => 'skip']); + $this->assertEquals('three', $first->nextPage); + $this->assertFalse($first->isFinalized()); + + // Completing page three finalizes, because every page on the path actually taken is done. + $result = app(SubmitForm::class) + ->form($form) + ->page('three') + ->resume($first->submission) + ->submit(['message' => 'Hello']); + + $this->assertNull($result->nextPage); + $this->assertTrue($result->isFinalized()); + + $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..1fa35074268 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 }} +
{{ 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_follows_page_logic_to_a_rules_destination_on_submit() + { + $this->createMultiPageFormWithLogic(); + Form::find('survey')->save(); + + // name=Olaf satisfies page one's rule, jumping straight to page three. + $this + ->from('/survey') + ->post('/!/forms/survey', ['_page' => 'page_one', 'name' => 'Olaf']) + ->assertSessionHasNoErrors() + ->assertRedirectContains('page=page_three'); + + Form::find('survey')->submissions()->each->delete(); + } + + #[Test] + public function it_sends_you_back_to_the_first_page_when_jumping_straight_past_a_required_field() + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + + $this->createForm([ + 'pages' => [ + [ + 'id' => 'page_one', + 'sections' => [['fields' => [['handle' => 'name', 'field' => ['type' => 'text', 'validate' => 'required']]]]], + ], + [ + 'id' => 'page_two', + 'sections' => [['fields' => [['handle' => 'email', 'field' => ['type' => 'text']]]]], + ], + ], + ], 'survey'); + Form::find('survey')->save(); + + // Jump straight to the final page, skipping page one's required field. + $this + ->from('/survey') + ->post('/!/forms/survey', ['_page' => 'page_two', 'email' => 'olaf@example.com']) + ->assertSessionHasNoErrors() + ->assertRedirectContains('page=page_one'); + + // The submission wasn't finalized; it's still partial. + $submissions = Form::find('survey')->submissions(); + $this->assertCount(1, $submissions); + $this->assertTrue($submissions->first()->isPartial()); + + Form::find('survey')->submissions()->each->delete(); + } + + #[Test] + public function it_does_not_redirect_to_an_external_url_from_the_referrer_between_pages() + { + $this->createMultiPageForm(); + Form::find('survey')->save(); + + // A forged referrer pointing off-site must not become the next-page redirect target. + $response = $this + ->from('https://evil.example/phishing') + ->post('/!/forms/survey', ['_page' => 'page_one', 'name' => 'Olaf']) + ->assertSessionHasNoErrors(); + + $this->assertStringNotContainsString('evil.example', $response->headers->get('Location')); + $response->assertRedirectContains('page=page_two'); + + Form::find('survey')->submissions()->each->delete(); + } + + #[Test] + public function the_previous_page_url_follows_the_path_taken_through_page_logic() + { + $this->createMultiPageFormWithLogic(); + + $form = Form::find('survey'); + $form->save(); + + // The user reached page three by jumping from page one (skipping page two). + $submission = tap($form->makeSubmission()->data(['name' => 'Olaf'])->asPartial())->save(); + session()->put('form.survey.partial_submission', $submission->id()); + + request()->merge(['page' => 'page_three']); + + $output = $this->tag('{{ form:survey }}{{ previous_page_url }}{{ /form:survey }}'); + + // "Back" returns to page one — the page actually visited — not page two. + $this->assertStringContainsString('page=page_one', $output); + $this->assertStringNotContainsString('page=page_two', $output); + + $form->submissions()->each->delete(); + } + + private function createMultiPageFormWithLogic($handle = 'survey') + { + Composer::shouldReceive('isInstalled')->with('statamic/forms-pro')->andReturn(true); + + $this->createForm([ + 'pages' => [ + [ + 'id' => 'page_one', + 'rules' => [[ + 'conditions' => [['field' => 'name', 'operator' => 'equals', 'value' => 'Olaf']], + 'destination' => 'page_three', + ]], + 'sections' => [['fields' => [['handle' => 'name', 'field' => ['type' => 'text']]]]], + ], + [ + 'id' => 'page_two', + 'sections' => [['fields' => [['handle' => 'colour', 'field' => ['type' => 'text']]]]], + ], + [ + 'id' => 'page_three', + 'previous_page_label' => 'Back', + 'sections' => [['fields' => [['handle' => 'email', 'field' => ['type' => 'text']]]]], + ], + ], + ], $handle); + } + #[Test] public function it_will_submit_form_and_follow_custom_redirect_with_success() { @@ -1269,6 +1530,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 }}