From 836490460eef6c7c65eb8430b6c68fb73f7d25a5 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 13:14:34 +0100 Subject: [PATCH 1/7] require the forms 2 branch of statamic/cms forms 2 hasn't shipped yet, so this points at the `forms-2` branch of statamic/cms for now, aliased as `6.31.0` so dependency constraints keep resolving. also sorts the `require` block alphabetically. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ATXH2uZVrsXqYrGqUoKhQA --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index d0b27d6e..987377a2 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,12 @@ ], "require": { "php": "^8.3", - "marcorieser/statamic-livewire": "^5.0", "laravel/framework": "^12.0 || ^13.0", "laravel/prompts": "^0.3.0", "livewire/livewire": "^4.1", + "marcorieser/statamic-livewire": "^5.0", "spatie/invade": "^2.0", - "statamic/cms": "^6.10" + "statamic/cms": "dev-forms-2 as 6.31.0" }, "require-dev": { "laravel/pint": "^1.27", From 8dc9b38d10b4b166b7bf3c5dd2b8c028eb5bea94 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 13:14:39 +0100 Subject: [PATCH 2/7] boot the test suite against forms 2 forms no longer have dedicated blueprint files, so blueprint deletion in the tests needs to be null-safe. the `Statamic` facade alias is also registered for the test app, as testbench doesn't register it and the toggle view relies on it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ATXH2uZVrsXqYrGqUoKhQA --- tests/Fields/FieldsTest.php | 2 +- tests/Livewire/FormTest.php | 2 +- tests/TestCase.php | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/Fields/FieldsTest.php b/tests/Fields/FieldsTest.php index c368eabd..d0ddf8c4 100644 --- a/tests/Fields/FieldsTest.php +++ b/tests/Fields/FieldsTest.php @@ -26,7 +26,7 @@ }); afterEach(function () { - Blueprint::find('forms.contact')->delete(); + Blueprint::find('forms.contact')?->delete(); StatamicForm::find('contact')->delete(); }); diff --git a/tests/Livewire/FormTest.php b/tests/Livewire/FormTest.php index aa1258d4..2402c598 100644 --- a/tests/Livewire/FormTest.php +++ b/tests/Livewire/FormTest.php @@ -21,7 +21,7 @@ }); afterEach(function () { - Blueprint::find('forms.contact')->delete(); + Blueprint::find('forms.contact')?->delete(); StatamicForm::find('contact')->delete(); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 2ea6e397..6aecfcc9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -22,6 +22,13 @@ protected function getPackageProviders($app) ]; } + protected function getPackageAliases($app) + { + return [ + 'Statamic' => \Statamic\Statamic::class, + ]; + } + protected function defineEnvironment($app) { $app->make(Manifest::class)->manifest = [ From c9f43a4fc5fd9d042ef0f07e5be46f19a5356cc9 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 13:14:45 +0100 Subject: [PATCH 3/7] resolve field models through parent fieldtypes when a fieldtype has no model binding of its own, fall back to the binding of its closest parent class. this lets subclassed fieldtypes, like forms 2's `YesNo` extending `Radio`, resolve without needing a binding of their own. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ATXH2uZVrsXqYrGqUoKhQA --- README.md | 2 ++ src/Livewire/Concerns/WithFields.php | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 265c3fb3..1538ebff 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,8 @@ Each Statamic fieldtype is mapped to a Livewire Forms field model. Models are re For instance, all the fields of type `\Statamic\Fieldtypes\Select::class` are bound to the `\Aerni\LivewireForms\Fields\Select::class` model. A field property is created for each model method ending with `Property`, e.g. `optionsProperty()` will generate an `options` property. +If a fieldtype has no binding of its own, the binding of its closest parent fieldtype is used instead, e.g. Statamic's `YesNo` fieldtype (the underlying fieldtype of the `yes_no` form fieldtype) resolves to the model of the `Radio` fieldtype it extends. + To change a field's default model, simply change the binding in the `models` property in your component: ```php diff --git a/src/Livewire/Concerns/WithFields.php b/src/Livewire/Concerns/WithFields.php index 4f72f836..52ab73be 100644 --- a/src/Livewire/Concerns/WithFields.php +++ b/src/Livewire/Concerns/WithFields.php @@ -65,13 +65,22 @@ protected function makeFieldFromModel(StatamicField $field): Field $fieldtype = $field->fieldtype()::class; $class = $this->models()->get($field->handle()) - ?? $this->models()->get($fieldtype); + ?? $this->modelFromFieldtype($fieldtype); return $class ? $class::make($field) : throw new \Exception("The field model binding for fieldtype [{$fieldtype}] cannot be found."); } + protected function modelFromFieldtype(string $fieldtype): ?string + { + /* Fall back to the model of a parent fieldtype, e.g. YesNo resolves to the model of Radio. */ + return collect([$fieldtype, ...class_parents($fieldtype)]) + ->map(fn ($class) => $this->models()->get($class)) + ->filter() + ->first(); + } + #[Computed] public function honeypot(): Honeypot { From 56e06852dd772d2998da2767a1d35cb4e9146db4 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 13:14:45 +0100 Subject: [PATCH 4/7] add field models and views for the new forms 2 fieldtypes adds models, config bindings, and default views for the fieldtypes introduced by forms 2: `date_picker`, `time_picker`, `star_rating`, `opinion_scale`, `ranking`, `image_choice`, `upload`, `group`, `heading`, `paragraph`, and `banner`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ATXH2uZVrsXqYrGqUoKhQA --- config/livewire-forms.php | 11 +++ .../views/default/fields/banner.blade.php | 19 +++++ .../views/default/fields/group.blade.php | 33 +++++++++ .../views/default/fields/heading.blade.php | 11 +++ .../default/fields/image_choice.blade.php | 60 ++++++++++++++++ .../default/fields/opinion_scale.blade.php | 59 +++++++++++++++ .../views/default/fields/paragraph.blade.php | 9 +++ .../views/default/fields/ranking.blade.php | 71 +++++++++++++++++++ .../default/fields/star_rating.blade.php | 68 ++++++++++++++++++ src/Fields/Date.php | 13 ++++ src/Fields/FormBanner.php | 15 ++++ src/Fields/FormHeading.php | 8 +++ src/Fields/FormUpload.php | 5 ++ src/Fields/Group.php | 57 +++++++++++++++ src/Fields/Html.php | 8 +++ src/Fields/ImageChoice.php | 28 ++++++++ src/Fields/OpinionScale.php | 13 ++++ src/Fields/Ranking.php | 18 +++++ src/Fields/StarRating.php | 13 ++++ src/Fields/Time.php | 30 ++++++++ 20 files changed, 549 insertions(+) create mode 100644 resources/views/default/fields/banner.blade.php create mode 100644 resources/views/default/fields/group.blade.php create mode 100644 resources/views/default/fields/heading.blade.php create mode 100644 resources/views/default/fields/image_choice.blade.php create mode 100644 resources/views/default/fields/opinion_scale.blade.php create mode 100644 resources/views/default/fields/paragraph.blade.php create mode 100644 resources/views/default/fields/ranking.blade.php create mode 100644 resources/views/default/fields/star_rating.blade.php create mode 100644 src/Fields/Date.php create mode 100644 src/Fields/FormBanner.php create mode 100644 src/Fields/FormHeading.php create mode 100644 src/Fields/FormUpload.php create mode 100644 src/Fields/Group.php create mode 100644 src/Fields/Html.php create mode 100644 src/Fields/ImageChoice.php create mode 100644 src/Fields/OpinionScale.php create mode 100644 src/Fields/Ranking.php create mode 100644 src/Fields/StarRating.php create mode 100644 src/Fields/Time.php diff --git a/config/livewire-forms.php b/config/livewire-forms.php index 848c7ed2..a23f295f 100644 --- a/config/livewire-forms.php +++ b/config/livewire-forms.php @@ -15,15 +15,26 @@ Aerni\LivewireForms\Fieldtypes\Captcha::class => Aerni\LivewireForms\Fields\Captcha::class, Statamic\Fieldtypes\Assets\Assets::class => Aerni\LivewireForms\Fields\Assets::class, Statamic\Fieldtypes\Checkboxes::class => Aerni\LivewireForms\Fields\Checkboxes::class, + Statamic\Fieldtypes\Date::class => Aerni\LivewireForms\Fields\Date::class, Statamic\Fieldtypes\Dictionary::class => Aerni\LivewireForms\Fields\Dictionary::class, Statamic\Fieldtypes\Files::class => Aerni\LivewireForms\Fields\Files::class, + Statamic\Fieldtypes\FormBanner::class => Aerni\LivewireForms\Fields\FormBanner::class, + Statamic\Fieldtypes\FormHeading::class => Aerni\LivewireForms\Fields\FormHeading::class, + Statamic\Fieldtypes\FormUpload::class => Aerni\LivewireForms\Fields\FormUpload::class, + Statamic\Fieldtypes\Group::class => Aerni\LivewireForms\Fields\Group::class, Statamic\Fieldtypes\Hidden::class => Aerni\LivewireForms\Fields\Hidden::class, + Statamic\Fieldtypes\Html::class => Aerni\LivewireForms\Fields\Html::class, + Statamic\Fieldtypes\ImageChoice::class => Aerni\LivewireForms\Fields\ImageChoice::class, Statamic\Fieldtypes\Integer::class => Aerni\LivewireForms\Fields\Integer::class, + Statamic\Fieldtypes\OpinionScale::class => Aerni\LivewireForms\Fields\OpinionScale::class, Statamic\Fieldtypes\Radio::class => Aerni\LivewireForms\Fields\Radio::class, + Statamic\Fieldtypes\Ranking::class => Aerni\LivewireForms\Fields\Ranking::class, Statamic\Fieldtypes\Select::class => Aerni\LivewireForms\Fields\Select::class, Statamic\Fieldtypes\Spacer::class => Aerni\LivewireForms\Fields\Spacer::class, + Statamic\Fieldtypes\StarRating::class => Aerni\LivewireForms\Fields\StarRating::class, Statamic\Fieldtypes\Text::class => Aerni\LivewireForms\Fields\Text::class, Statamic\Fieldtypes\Textarea::class => Aerni\LivewireForms\Fields\Textarea::class, + Statamic\Fieldtypes\Time::class => Aerni\LivewireForms\Fields\Time::class, Statamic\Fieldtypes\Toggle::class => Aerni\LivewireForms\Fields\Toggle::class, ], diff --git a/resources/views/default/fields/banner.blade.php b/resources/views/default/fields/banner.blade.php new file mode 100644 index 00000000..cbe2accf --- /dev/null +++ b/resources/views/default/fields/banner.blade.php @@ -0,0 +1,19 @@ +
+ @if($field->icon) +
+ {!! $field->icon !!} +
+ @endif + +
+

+ {{ $field->display }} +

+ + @if($field->text) +

+ {{ $field->text }} +

+ @endif +
+
diff --git a/resources/views/default/fields/group.blade.php b/resources/views/default/fields/group.blade.php new file mode 100644 index 00000000..a9377d94 --- /dev/null +++ b/resources/views/default/fields/group.blade.php @@ -0,0 +1,33 @@ +
+
+ @formView('messages.legend') + + @if($field->instructions && $field->instructions_position === 'above') + @formView('messages.instructions') + @endif + +
+ @foreach($field->fields() as $groupField) +
+ @formView($groupField->view, ['field' => $groupField]) +
+ @endforeach +
+ + @if($errors->has($field->key)) + @formView('messages.error') + @elseif($field->instructions && $field->instructions_position === 'below') + @formView('messages.instructions') + @endif +
+
diff --git a/resources/views/default/fields/heading.blade.php b/resources/views/default/fields/heading.blade.php new file mode 100644 index 00000000..bbd672b8 --- /dev/null +++ b/resources/views/default/fields/heading.blade.php @@ -0,0 +1,11 @@ +
+

+ {{ $field->display }} +

+ + @if($field->subheading) +

+ {{ $field->subheading }} +

+ @endif +
diff --git a/resources/views/default/fields/image_choice.blade.php b/resources/views/default/fields/image_choice.blade.php new file mode 100644 index 00000000..59298119 --- /dev/null +++ b/resources/views/default/fields/image_choice.blade.php @@ -0,0 +1,60 @@ +@php($columns = ['sm:grid-cols-1', 'sm:grid-cols-2', 'sm:grid-cols-3', 'sm:grid-cols-4'][$field->columns - 1] ?? 'sm:grid-cols-3') + +
+
+ @formView('messages.legend') + + @if($field->instructions && $field->instructions_position === 'above') + @formView('messages.instructions') + @endif + +
+ @foreach($field->options as $option) +
+ wire_model) + wire:model.{{ $field->wire_model }}="{{ $field->key }}" + @else + wire:model="{{ $field->key }}" + @endif + + @if($errors->has($field->key)) + aria-invalid="true" + aria-describedby="{{ $field->id }}-error" + @elseif($field->instructions) + aria-describedby="{{ $field->id }}-instructions" + @endif + /> + + +
+ @endforeach +
+ + @if($errors->has($field->key)) + @formView('messages.error') + @elseif($field->instructions && $field->instructions_position === 'below') + @formView('messages.instructions') + @endif +
+
diff --git a/resources/views/default/fields/opinion_scale.blade.php b/resources/views/default/fields/opinion_scale.blade.php new file mode 100644 index 00000000..2461d359 --- /dev/null +++ b/resources/views/default/fields/opinion_scale.blade.php @@ -0,0 +1,59 @@ +
+
+ @formView('messages.legend') + + @if($field->instructions && $field->instructions_position === 'above') + @formView('messages.instructions') + @endif + +
+
+ @foreach($field->scale_values as $value) +
+ wire_model) + wire:model.{{ $field->wire_model }}="{{ $field->key }}" + @else + wire:model="{{ $field->key }}" + @endif + + @if($errors->has($field->key)) + aria-invalid="true" + aria-describedby="{{ $field->id }}-error" + @elseif($field->instructions) + aria-describedby="{{ $field->id }}-instructions" + @endif + /> + + +
+ @endforeach +
+ + @if($field->low_label || $field->middle_label || $field->high_label) +
+ {{ $field->low_label }} + {{ $field->middle_label }} + {{ $field->high_label }} +
+ @endif +
+ + @if($errors->has($field->key)) + @formView('messages.error') + @elseif($field->instructions && $field->instructions_position === 'below') + @formView('messages.instructions') + @endif +
+
diff --git a/resources/views/default/fields/paragraph.blade.php b/resources/views/default/fields/paragraph.blade.php new file mode 100644 index 00000000..03aa14bf --- /dev/null +++ b/resources/views/default/fields/paragraph.blade.php @@ -0,0 +1,9 @@ +@if($field->html) +
+ {!! $field->html !!} +
+@else +

+ {{ $field->text }} +

+@endif diff --git a/resources/views/default/fields/ranking.blade.php b/resources/views/default/fields/ranking.blade.php new file mode 100644 index 00000000..aeda3ce3 --- /dev/null +++ b/resources/views/default/fields/ranking.blade.php @@ -0,0 +1,71 @@ +
+
+ @formView('messages.legend') + + @if($field->instructions && $field->instructions_position === 'above') + @formView('messages.instructions') + @endif + +
    has($field->key)) + aria-invalid="true" + aria-describedby="{{ $field->id }}-error" + @elseif($field->instructions) + aria-describedby="{{ $field->id }}-instructions" + @endif + > + @foreach($field->value ?? [] as $index => $key) +
  1. + + {{ $index + 1 }}. + {{ $field->options[$key] ?? $key }} + + + + + + + +
  2. + @endforeach +
+ + @if($errors->has($field->key)) + @formView('messages.error') + @elseif($field->instructions && $field->instructions_position === 'below') + @formView('messages.instructions') + @endif +
+
diff --git a/resources/views/default/fields/star_rating.blade.php b/resources/views/default/fields/star_rating.blade.php new file mode 100644 index 00000000..ac6ab9d3 --- /dev/null +++ b/resources/views/default/fields/star_rating.blade.php @@ -0,0 +1,68 @@ +@formView('messages.display') + +@if($field->instructions && $field->instructions_position === 'above') + @formView('messages.instructions') +@endif + +
has($field->key)) + aria-invalid="true" + aria-describedby="{{ $field->id }}-error" + @elseif($field->instructions) + aria-describedby="{{ $field->id }}-instructions" + @endif +> + @foreach(range(1, $field->max_stars) as $star) +
+ + +
+ +
+ + @foreach($field->step == 0.5 ? [$star - 0.5, $star] : [$star] as $rating) + + @endforeach +
+ @endforeach +
+ +@if($errors->has($field->key)) + @formView('messages.error') +@elseif($field->instructions && $field->instructions_position === 'below') + @formView('messages.instructions') +@endif diff --git a/src/Fields/Date.php b/src/Fields/Date.php new file mode 100644 index 00000000..966f3312 --- /dev/null +++ b/src/Fields/Date.php @@ -0,0 +1,13 @@ +field->get('time_enabled') ? 'datetime-local' : 'date'; + } +} diff --git a/src/Fields/FormBanner.php b/src/Fields/FormBanner.php new file mode 100644 index 00000000..a41344fd --- /dev/null +++ b/src/Fields/FormBanner.php @@ -0,0 +1,15 @@ +field->fieldtype()->extraRenderableFieldData(), 'icon'); + } +} diff --git a/src/Fields/FormHeading.php b/src/Fields/FormHeading.php new file mode 100644 index 00000000..355c155d --- /dev/null +++ b/src/Fields/FormHeading.php @@ -0,0 +1,8 @@ +field->fieldtype()->fields()->all() + ->map(fn (StatamicField $field) => $this->makeNestedField($field)); + } + + protected function makeNestedField(StatamicField $field): Field + { + $model = invade(Livewire::current())->makeFieldFromModel($field); + + return $model->properties([ + ...$model->properties(), + 'key' => "{$this->key}.{$field->handle()}", + 'id' => "{$this->id}-{$field->handle()}", + ]); + } + + protected function defaultProperty(mixed $default = null): array + { + return $default ?? $this->fields()->map(fn (Field $field) => $field->default)->toArray(); + } + + protected function rulesProperty(string|array|null $rules = null): array + { + if (! is_null($rules)) { + return parent::rulesProperty($rules); + } + + /* The group's rules also contain the rules of its nested fields, e.g. "group.name". */ + return collect($this->field->rules()) + ->mapWithKeys(fn ($rules, $handle) => [ + $this->key.Str::after($handle, $this->handle) => $rules, + ]) + ->toArray(); + } + + public function validationAttributes(): array + { + return [ + $this->key => $this->display, + ...$this->fields()->mapWithKeys(fn (Field $field) => [$field->key => $field->display]), + ]; + } +} diff --git a/src/Fields/Html.php b/src/Fields/Html.php new file mode 100644 index 00000000..b10394a5 --- /dev/null +++ b/src/Fields/Html.php @@ -0,0 +1,8 @@ +field->fieldtype()->extraRenderableFieldData(), 'options', []); + } + + protected function defaultProperty(mixed $default = null): string|array|null + { + $default = collect($this->options) + ->pluck('key') + ->intersect(Arr::wrap($default ?? $this->field->defaultValue())) + ->values(); + + return $this->multiple ? $default->toArray() : $default->first(); + } +} diff --git a/src/Fields/OpinionScale.php b/src/Fields/OpinionScale.php new file mode 100644 index 00000000..d79cdd71 --- /dev/null +++ b/src/Fields/OpinionScale.php @@ -0,0 +1,13 @@ +field->get('min', 0), $this->field->get('max', 10)); + } +} diff --git a/src/Fields/Ranking.php b/src/Fields/Ranking.php new file mode 100644 index 00000000..f1c2550f --- /dev/null +++ b/src/Fields/Ranking.php @@ -0,0 +1,18 @@ +options); + } +} diff --git a/src/Fields/StarRating.php b/src/Fields/StarRating.php new file mode 100644 index 00000000..9e111c02 --- /dev/null +++ b/src/Fields/StarRating.php @@ -0,0 +1,13 @@ +field->get('max_stars', 5); + } +} diff --git a/src/Fields/Time.php b/src/Fields/Time.php new file mode 100644 index 00000000..d506a82c --- /dev/null +++ b/src/Fields/Time.php @@ -0,0 +1,30 @@ +field->get('seconds_enabled') ? 'H:i:s' : 'H:i'; + + return collect(parent::rulesProperty($rules)) + ->map(fn ($rules) => collect($rules) + ->map(fn ($rule) => $rule instanceof TimeFieldtype ? "date_format:{$format}" : $rule) + ->all()) + ->all(); + } +} From cdb90ac729cf500556c91d6ae52e212bb076fdce Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 13:14:51 +0100 Subject: [PATCH 5/7] submit forms through statamic's `SubmitForm` action when the `SubmitForm` action exists, submissions are handled by statamic itself so spam protection, restrictions, file uploads, events, and emails behave exactly like a native form submission. validation errors are mapped back to their livewire error keys, and restricted forms surface their message as a form-level error. older statamic versions continue to use the addon's own submission flow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ATXH2uZVrsXqYrGqUoKhQA --- src/Livewire/Concerns/HandlesSubmission.php | 78 +++++++++++++++++++++ src/Livewire/Concerns/SubmitsForm.php | 6 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/Livewire/Concerns/HandlesSubmission.php b/src/Livewire/Concerns/HandlesSubmission.php index aeb7d559..af83f245 100644 --- a/src/Livewire/Concerns/HandlesSubmission.php +++ b/src/Livewire/Concerns/HandlesSubmission.php @@ -2,7 +2,11 @@ namespace Aerni\LivewireForms\Livewire\Concerns; +use Aerni\LivewireForms\Fields\Assets; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\URL; +use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use Statamic\Contracts\Forms\Submission; use Statamic\Events\FormSubmitted; use Statamic\Events\SubmissionCreated; @@ -10,6 +14,7 @@ use Statamic\Exceptions\SilentFormFailureException; use Statamic\Facades\Site; use Statamic\Forms\SendEmails; +use Statamic\Forms\SubmitForm; trait HandlesSubmission { @@ -17,13 +22,86 @@ trait HandlesSubmission protected function handleSubmission(): self { + /** + * Statamic versions that ship the SubmitForm action handle spam, restrictions, + * events, file uploads, and emails in the action itself. Older versions rely on + * the addon's own submission flow below. This lets us support both versions + * without a breaking release. The old flow can be dropped once this addon + * requires a Statamic version that ships the action. + */ + if (class_exists(SubmitForm::class)) { + return $this->submitForm(); + } + return $this + ->handleSpam() ->makeSubmission() ->handleFormEvents() ->saveSubmission() ->sendEmails(); } + /** + * Submit the form using Statamic's own action so that features like + * spam protection, submission restrictions, emails, and file uploads + * behave exactly like a regular Statamic form submission. + */ + protected function submitForm(): self + { + [$files, $values] = $this->fields + ->filter(fn ($field) => $this->submittableFields[$field->handle]) + ->partition(fn ($field) => $field instanceof Assets); + + $action = app(SubmitForm::class)->form($this->form); + + /** + * Livewire can batch multiple component calls into a single request. Only react + * to the submission of this component's action so that forms submitted in the + * same request don't cross-fire each other's hooks and events. + */ + Event::listen(FormSubmitted::class, function (FormSubmitted $event) use ($action) { + if ($event->submission !== $action->submission()) { + return; + } + + $this->formSubmitted($event->submission); + $this->dispatch('form-submitted', data: $event->submission->data()); + }); + + try { + $this->submission = $action->submit( + data: $values->map(fn ($field) => $field->value())->all(), + files: $files->map(fn ($field) => $field->value())->filter()->all(), + )->submission; + } catch (ValidationException $exception) { + throw $this->validationExceptionWithFieldKeys($exception); + } catch (SilentFormFailureException $exception) { + /* Keep the submission out of the session when the honeypot caught spam. */ + if (! $this->isSpam()) { + $this->submission = $action->submission(); + } + + throw $exception; + } + + return $this; + } + + protected function validationExceptionWithFieldKeys(ValidationException $exception): ValidationException + { + return ValidationException::withMessages( + collect($exception->errors()) + ->mapWithKeys(function ($messages, $key) { + if (! $this->fields->has(Str::before($key, '.'))) { + return [$key => $messages]; + } + + return [preg_replace('/^([^.]+)/', 'fields.$1.value', $key) => $messages]; + }) + ->all() + ); + } + protected function makeSubmission(): self { $this->submission = $this->form->makeSubmission()->data($this->processedValues()); diff --git a/src/Livewire/Concerns/SubmitsForm.php b/src/Livewire/Concerns/SubmitsForm.php index 081464b2..a3efb87a 100644 --- a/src/Livewire/Concerns/SubmitsForm.php +++ b/src/Livewire/Concerns/SubmitsForm.php @@ -2,6 +2,8 @@ namespace Aerni\LivewireForms\Livewire\Concerns; +use Illuminate\Validation\ValidationException; +use Statamic\Exceptions\FormRestrictedException; use Statamic\Exceptions\SilentFormFailureException; trait SubmitsForm @@ -16,9 +18,11 @@ public function submit(): void $this->validate(); try { - $this->handleSpam()->handleSubmission()->handleSuccess(); + $this->handleSubmission()->handleSuccess(); } catch (SilentFormFailureException) { $this->handleSuccess(); + } catch (FormRestrictedException $exception) { + throw ValidationException::withMessages(['*' => $exception->getMessage()]); } } } From 8b1f8fbfc2c614fa8862d8305706e7945190d849 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 2 Sep 2026 13:14:52 +0100 Subject: [PATCH 6/7] add forms 2 test suite covers rendering and field model resolution of the new fieldtypes, persisting submissions, file uploads, restrictions, event isolation between forms submitted in the same request, and validation error mapping. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01ATXH2uZVrsXqYrGqUoKhQA --- tests/Livewire/FormsTwoTest.php | 186 ++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/Livewire/FormsTwoTest.php diff --git a/tests/Livewire/FormsTwoTest.php b/tests/Livewire/FormsTwoTest.php new file mode 100644 index 00000000..8ab7b151 --- /dev/null +++ b/tests/Livewire/FormsTwoTest.php @@ -0,0 +1,186 @@ +markTestSkipped('Requires a Statamic version that ships with the SubmitForm action.'); + } + + StatamicForm::make('survey')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer', 'validate' => 'required']], + ['handle' => 'about', 'field' => ['type' => 'long_answer']], + ['handle' => 'topic', 'field' => ['type' => 'dropdown', 'options' => ['support' => 'Support', 'sales' => 'Sales']]], + ['handle' => 'color', 'field' => ['type' => 'multi_choice', 'options' => ['red' => 'Red', 'blue' => 'Blue']]], + ['handle' => 'interests', 'field' => ['type' => 'checkboxes', 'options' => ['tech' => 'Tech', 'design' => 'Design']]], + ['handle' => 'newsletter', 'field' => ['type' => 'toggle']], + ['handle' => 'recommend', 'field' => ['type' => 'yes_no']], + ['handle' => 'birthday', 'field' => ['type' => 'date_picker']], + ['handle' => 'alarm', 'field' => ['type' => 'time_picker']], + ['handle' => 'age', 'field' => ['type' => 'number']], + ['handle' => 'rating', 'field' => ['type' => 'star_rating']], + ['handle' => 'score', 'field' => ['type' => 'opinion_scale']], + ['handle' => 'priorities', 'field' => ['type' => 'ranking', 'options' => ['speed' => 'Speed', 'quality' => 'Quality']]], + ['handle' => 'style', 'field' => ['type' => 'image_choice', 'options' => [['key' => 'light', 'label' => 'Light'], ['key' => 'dark', 'label' => 'Dark']]]], + ['handle' => 'resume', 'field' => ['type' => 'upload']], + ['handle' => 'address', 'field' => ['type' => 'group', 'fields' => [ + ['handle' => 'street', 'field' => ['type' => 'text']], + ['handle' => 'city', 'field' => ['type' => 'text']], + ]]], + ['handle' => 'intro', 'field' => ['type' => 'heading', 'display' => 'About you', 'subheading' => 'Tell us more']], + ['handle' => 'terms', 'field' => ['type' => 'paragraph', 'text' => 'Some fine print']], + ['handle' => 'notice', 'field' => ['type' => 'banner', 'display' => 'Heads up', 'text' => 'Please read this']], + ['handle' => 'gap', 'field' => ['type' => 'spacer']], + ], + ])->save(); +}); + +afterEach(function () { + StatamicForm::find('survey')?->delete(); +}); + +it('renders a form containing every fieldtype', function () { + Livewire::test(BaseForm::class, ['handle' => 'survey']) + ->assertStatus(200); +}); + +it('resolves the field models of the new fieldtypes', function () { + $fields = Livewire::test(BaseForm::class, ['handle' => 'survey']) + ->instance() + ->fields; + + expect($fields->get('recommend'))->toBeInstanceOf(Fields\Radio::class) + ->and($fields->get('birthday'))->toBeInstanceOf(Fields\Date::class) + ->and($fields->get('alarm'))->toBeInstanceOf(Fields\Time::class) + ->and($fields->get('rating'))->toBeInstanceOf(Fields\StarRating::class) + ->and($fields->get('score'))->toBeInstanceOf(Fields\OpinionScale::class) + ->and($fields->get('priorities'))->toBeInstanceOf(Fields\Ranking::class) + ->and($fields->get('style'))->toBeInstanceOf(Fields\ImageChoice::class) + ->and($fields->get('resume'))->toBeInstanceOf(Fields\FormUpload::class) + ->and($fields->get('address'))->toBeInstanceOf(Fields\Group::class) + ->and($fields->get('intro'))->toBeInstanceOf(Fields\FormHeading::class) + ->and($fields->get('terms'))->toBeInstanceOf(Fields\Html::class) + ->and($fields->get('notice'))->toBeInstanceOf(Fields\FormBanner::class); +}); + +it('persists a submission with the values of the new fieldtypes', function () { + filledSurveyForm([ + 'recommend' => 'yes', + 'birthday' => '1990-04-24', + 'alarm' => '07:30', + 'rating' => 4, + 'score' => 7, + 'priorities' => ['quality', 'speed'], + 'style' => 'dark', + 'address' => ['street' => 'Milson Road', 'city' => 'London'], + ]) + ->call('submit') + ->assertSuccessful() + ->assertSee('The form was successfully submitted!'); + + $submission = StatamicForm::find('survey')->submissions()->first(); + + expect($submission->get('recommend'))->toBe('yes') + ->and($submission->get('birthday'))->toBe('1990-04-24') + ->and($submission->get('alarm'))->toBe('07:30') + ->and($submission->get('rating'))->toBe(4) + ->and($submission->get('score'))->toBe(7) + ->and($submission->get('priorities'))->toBe(['quality', 'speed']) + ->and($submission->get('style'))->toBe('dark') + ->and($submission->get('address'))->toBe(['street' => 'Milson Road', 'city' => 'London']); +}); + +it('uploads files through the upload fieldtype', function () { + /* Statamic deletes the temporary files of non-stored upload fields after the emails have been sent. */ + Bus::fake(DeleteTemporaryFiles::class); + + filledSurveyForm(['resume' => [UploadedFile::fake()->create('resume.pdf', 100)]]) + ->call('submit') + ->assertSuccessful() + ->assertSee('The form was successfully submitted!'); + + $submission = StatamicForm::find('survey')->submissions()->first(); + + expect($submission->get('resume'))->toBe(["{$submission->id()}/resume/resume.pdf"]); + + Storage::disk(config('statamic.system.file_uploads_disk', 'local')) + ->assertExists("statamic/form-uploads/{$submission->id()}/resume/resume.pdf"); +}); + +it('ignores the FormSubmitted event of other forms submitted in the same request', function () { + StatamicForm::make('other')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ], + ])->save(); + + Event::listen(FormSubmitted::class, function (FormSubmitted $event) { + if ($event->submission->form()->handle() === 'survey') { + app(SubmitForm::class)->form(StatamicForm::find('other'))->submit(['name' => 'Intruder']); + } + }); + + filledSurveyForm() + ->call('submit') + ->assertNotDispatched('form-submitted', fn ($event, $params) => $params['data']['name'] === 'Intruder') + ->assertDispatched('form-submitted', fn ($event, $params) => $params['data']['name'] === 'Michael'); + + StatamicForm::find('other')->delete(); +}); + +it('shows the restriction message when the form is restricted', function () { + tap(StatamicForm::find('survey')) + ->set('close_date', now()->subDay()->format('Y-m-d H:i')) + ->save(); + + filledSurveyForm() + ->call('submit') + ->assertHasErrors('*'); + + expect(StatamicForm::find('survey')->submissions())->toHaveCount(0); +}); + +it('maps the validation errors of the submit action to their Livewire error keys', function (array $messages, string $expectedErrorKey) { + app()->bind(SubmitForm::class, fn () => new class($messages) extends SubmitForm + { + public function __construct(protected array $messages) {} + + public function submit(array $data, array $files = []): SubmissionResult + { + throw ValidationException::withMessages($this->messages); + } + }); + + filledSurveyForm() + ->call('submit') + ->assertHasErrors($expectedErrorKey); +})->with([ + 'field error' => [['name' => ['The name is invalid.']], 'fields.name.value'], + 'form-level error' => [['*' => ['This form must be submitted from an entry.']], '*'], +]); + +function filledSurveyForm(array $values = []): \Livewire\Features\SupportTesting\Testable +{ + $component = Livewire::test(BaseForm::class, ['handle' => 'survey']); + + $component->set('submittableFields', $component->instance()->fields->map(fn () => true)->all()); + + foreach (['name' => 'Michael', ...$values] as $handle => $value) { + $component->set("fields.{$handle}.value", $value); + } + + return $component; +} From 8cc21402fb77823c44261c7de702ef82081b63f9 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 14 Oct 2026 22:55:09 +0100 Subject: [PATCH 7/7] handle the \`info\` fieldtype replacing \`form_banner\` in core --- config/livewire-forms.php | 2 +- resources/views/default/fields/banner.blade.php | 4 ++-- src/Fields/{FormBanner.php => Info.php} | 8 +++++--- tests/Livewire/FormsTwoTest.php | 4 ++-- 4 files changed, 10 insertions(+), 8 deletions(-) rename src/Fields/{FormBanner.php => Info.php} (51%) diff --git a/config/livewire-forms.php b/config/livewire-forms.php index a23f295f..da3b249c 100644 --- a/config/livewire-forms.php +++ b/config/livewire-forms.php @@ -18,13 +18,13 @@ Statamic\Fieldtypes\Date::class => Aerni\LivewireForms\Fields\Date::class, Statamic\Fieldtypes\Dictionary::class => Aerni\LivewireForms\Fields\Dictionary::class, Statamic\Fieldtypes\Files::class => Aerni\LivewireForms\Fields\Files::class, - Statamic\Fieldtypes\FormBanner::class => Aerni\LivewireForms\Fields\FormBanner::class, Statamic\Fieldtypes\FormHeading::class => Aerni\LivewireForms\Fields\FormHeading::class, Statamic\Fieldtypes\FormUpload::class => Aerni\LivewireForms\Fields\FormUpload::class, Statamic\Fieldtypes\Group::class => Aerni\LivewireForms\Fields\Group::class, Statamic\Fieldtypes\Hidden::class => Aerni\LivewireForms\Fields\Hidden::class, Statamic\Fieldtypes\Html::class => Aerni\LivewireForms\Fields\Html::class, Statamic\Fieldtypes\ImageChoice::class => Aerni\LivewireForms\Fields\ImageChoice::class, + Statamic\Fieldtypes\Info::class => Aerni\LivewireForms\Fields\Info::class, Statamic\Fieldtypes\Integer::class => Aerni\LivewireForms\Fields\Integer::class, Statamic\Fieldtypes\OpinionScale::class => Aerni\LivewireForms\Fields\OpinionScale::class, Statamic\Fieldtypes\Radio::class => Aerni\LivewireForms\Fields\Radio::class, diff --git a/resources/views/default/fields/banner.blade.php b/resources/views/default/fields/banner.blade.php index cbe2accf..c7632301 100644 --- a/resources/views/default/fields/banner.blade.php +++ b/resources/views/default/fields/banner.blade.php @@ -10,9 +10,9 @@ {{ $field->display }} - @if($field->text) + @if($field->content)

- {{ $field->text }} + {{ $field->content }}

@endif diff --git a/src/Fields/FormBanner.php b/src/Fields/Info.php similarity index 51% rename from src/Fields/FormBanner.php rename to src/Fields/Info.php index a41344fd..4e559620 100644 --- a/src/Fields/FormBanner.php +++ b/src/Fields/Info.php @@ -2,14 +2,16 @@ namespace Aerni\LivewireForms\Fields; -use Illuminate\Support\Arr; +use Statamic\Facades\Icon; -class FormBanner extends Field +class Info extends Field { protected string $view = 'banner'; protected function iconProperty(?string $icon = null): ?string { - return $icon ?? Arr::get($this->field->fieldtype()->extraRenderableFieldData(), 'icon'); + $icon ??= $this->field->get('icon'); + + return $icon ? Icon::default()->get($icon) : null; } } diff --git a/tests/Livewire/FormsTwoTest.php b/tests/Livewire/FormsTwoTest.php index 8ab7b151..3e4b99a5 100644 --- a/tests/Livewire/FormsTwoTest.php +++ b/tests/Livewire/FormsTwoTest.php @@ -42,7 +42,7 @@ ]]], ['handle' => 'intro', 'field' => ['type' => 'heading', 'display' => 'About you', 'subheading' => 'Tell us more']], ['handle' => 'terms', 'field' => ['type' => 'paragraph', 'text' => 'Some fine print']], - ['handle' => 'notice', 'field' => ['type' => 'banner', 'display' => 'Heads up', 'text' => 'Please read this']], + ['handle' => 'notice', 'field' => ['type' => 'banner', 'display' => 'Heads up', 'content' => 'Please read this']], ['handle' => 'gap', 'field' => ['type' => 'spacer']], ], ])->save(); @@ -73,7 +73,7 @@ ->and($fields->get('address'))->toBeInstanceOf(Fields\Group::class) ->and($fields->get('intro'))->toBeInstanceOf(Fields\FormHeading::class) ->and($fields->get('terms'))->toBeInstanceOf(Fields\Html::class) - ->and($fields->get('notice'))->toBeInstanceOf(Fields\FormBanner::class); + ->and($fields->get('notice'))->toBeInstanceOf(Fields\Info::class); }); it('persists a submission with the values of the new fieldtypes', function () {