Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
0a334b9
wip
duncanmcclean Jun 16, 2026
14e4257
rename draft to incomplete
duncanmcclean Jun 17, 2026
dc3a5f5
push filters into the query string like other listings
duncanmcclean Jun 17, 2026
059b185
wip
duncanmcclean Jun 17, 2026
8f2a870
show complete submissions by default
duncanmcclean Jun 17, 2026
cc5fc42
add status indicator to submissions
duncanmcclean Jun 17, 2026
33fd58f
a few leftover bits from the rename
duncanmcclean Jun 17, 2026
9c7fb63
remove `isWithheld` method
duncanmcclean Jun 17, 2026
65fb4bf
formatting
duncanmcclean Jun 17, 2026
e53afa5
Merge branch 'forms-2' into forms-2-submission-statuses
duncanmcclean Jun 17, 2026
49995d4
fix test namespace
duncanmcclean Jun 17, 2026
7cd4b22
skip new tests temporarily to see if it fixes the failing tests
duncanmcclean Jun 17, 2026
d6c8ca8
wip
duncanmcclean Jun 17, 2026
ec60564
wip
duncanmcclean Jun 17, 2026
3305cff
fix failing tests
duncanmcclean Jun 17, 2026
41fc2f6
Extract form submission logic into `SubmitForm` action
duncanmcclean Jun 18, 2026
f644710
Merge branch 'forms-2' into forms-2-submission-statuses
duncanmcclean Jun 19, 2026
7cc2d48
Merge branch 'forms-2' into forms-2-submission-statuses
duncanmcclean Jun 22, 2026
ad5958b
Merge branch 'forms-2-submission-statuses' into forms-2-submit-form-a…
duncanmcclean Jun 22, 2026
ba87c32
Add `saveDraft` method
duncanmcclean Jun 23, 2026
af75c2a
provide `pages` variable in `form:create` tag
duncanmcclean Jun 23, 2026
f68410a
allow js drivers to add renderable data to pages
duncanmcclean Jun 23, 2026
c722435
add missing import
duncanmcclean Jun 24, 2026
aaa19c0
rename `saveDraft` to `saveIncomplete` to avoid confusion
duncanmcclean Jun 24, 2026
c487fb5
Merge branch 'forms-2' into forms-2-submit-form-action
duncanmcclean Jun 25, 2026
f807007
wip
duncanmcclean Jun 25, 2026
aff6db7
delete mark not spam action. not necessary in this pr
duncanmcclean Jun 25, 2026
f039d10
job has been renamed. delete old versions
duncanmcclean Jun 25, 2026
99a1ab8
update tests
duncanmcclean Jun 25, 2026
b29330e
drop `saveIncomplete` in favour of `asPartial`
duncanmcclean Jun 25, 2026
e410291
wip
duncanmcclean Jun 25, 2026
2f3f084
Merge branch 'forms-2-tag-data' into forms-2-submit-form-action
duncanmcclean Jun 25, 2026
8fc800d
wip
duncanmcclean Jun 26, 2026
22c2545
wip
duncanmcclean Jun 26, 2026
333e099
wip
duncanmcclean Jun 26, 2026
a4857cd
wip
duncanmcclean Jun 26, 2026
48c07a9
wip
duncanmcclean Jun 26, 2026
b7101dc
wip
duncanmcclean Jun 26, 2026
File filter

Filter by extension

Filter by extension

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

'delete_partial_submissions_after' => 7,

'garbage_collect_assets' => false,

/*
|--------------------------------------------------------------------------
| Exporters
Expand Down
3 changes: 2 additions & 1 deletion routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
Expand Down
12 changes: 11 additions & 1 deletion src/Exceptions/SilentFormFailureException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
17 changes: 8 additions & 9 deletions src/Forms/Fields/FormFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
]);
}

Expand Down Expand Up @@ -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 [
Expand All @@ -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,
],
Expand Down
9 changes: 9 additions & 0 deletions src/Forms/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
12 changes: 12 additions & 0 deletions src/Forms/JsDrivers/AbstractJsDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
2 changes: 2 additions & 0 deletions src/Forms/JsDrivers/JsDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/Forms/Submission.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions src/Forms/SubmissionResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Statamic\Forms;

use Statamic\Contracts\Forms\Submission;

readonly class SubmissionResult
{
public function __construct(
public Submission $submission,
public ?string $nextPage = null
) {
}

public function isFinalized(): bool
{
return $this->submission->status() === 'finalized';
}
}
210 changes: 210 additions & 0 deletions src/Forms/SubmitForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php

namespace Statamic\Forms;

use Illuminate\Support\Traits\Localizable;
use Illuminate\Validation\ValidationException;
use Statamic\Contracts\Forms\Form;
use Statamic\Contracts\Forms\Submission;
use Statamic\Events\FormSubmitted;
use Statamic\Exceptions\SilentFormFailureException;
use Statamic\Facades\Asset;
use Statamic\Facades\Site;
use Statamic\Rules\AllowedFile;
use Statamic\Support\Arr;

class SubmitForm
{
use Localizable;

protected Form $form;
protected ?string $page = null;
protected ?Submission $submission = null;

public function form(Form $form): static
{
$this->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;
}
}
Loading
Loading