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/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",
diff --git a/config/livewire-forms.php b/config/livewire-forms.php
index 848c7ed2..da3b249c 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\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,
+ 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..c7632301
--- /dev/null
+++ b/resources/views/default/fields/banner.blade.php
@@ -0,0 +1,19 @@
+
+ @if($field->icon)
+
+ {!! $field->icon !!}
+
+ @endif
+
+
+
+ {{ $field->display }}
+
+
+ @if($field->content)
+
+ {{ $field->content }}
+
+ @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
+ />
+
+
+ @if($option['image'])
+
+ @endif
+
+
+
+ {{ $option['letter'] }}
+
+ {{ $option['label'] }}
+
+
+
+ @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
+ />
+
+
+ {{ $value }}
+
+
+ @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)
+
+
+ {{ $index + 1 }}.
+ {{ $field->options[$key] ?? $key }}
+
+
+
+ first)
+ >
+
+
+
+
+
+ last)
+ >
+
+
+
+
+
+
+ @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)
+
+ {{ $rating }}
+
+ wire_model)
+ wire:model.{{ $field->wire_model }}="{{ $field->key }}"
+ @else
+ wire:model="{{ $field->key }}"
+ @endif
+ />
+
+ @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/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/Info.php b/src/Fields/Info.php
new file mode 100644
index 00000000..4e559620
--- /dev/null
+++ b/src/Fields/Info.php
@@ -0,0 +1,17 @@
+field->get('icon');
+
+ return $icon ? Icon::default()->get($icon) : null;
+ }
+}
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();
+ }
+}
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()]);
}
}
}
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
{
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/Livewire/FormsTwoTest.php b/tests/Livewire/FormsTwoTest.php
new file mode 100644
index 00000000..3e4b99a5
--- /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', 'content' => '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\Info::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;
+}
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 = [