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 + /> + + +
+ @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/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 = [