From f429670db3ab99a210fcd28d8fbee932654635a2 Mon Sep 17 00:00:00 2001 From: Dan Harrin Date: Fri, 11 Jul 2025 11:10:15 +0100 Subject: [PATCH] Refactor select option validation --- packages/forms/docs/03-select.md | 72 +++++-- packages/forms/docs/23-validation.md | 3 + .../Components/Concerns/CanBeValidated.php | 4 +- .../Components/Concerns/CanDisableOptions.php | 36 +++- packages/forms/src/Components/Select.php | 185 ++++++++++++------ tests/src/Forms/Components/SelectTest.php | 176 +++++++++++++++++ 6 files changed, 389 insertions(+), 87 deletions(-) create mode 100644 tests/src/Forms/Components/SelectTest.php diff --git a/packages/forms/docs/03-select.md b/packages/forms/docs/03-select.md index 0ff67ee88d..840920e556 100644 --- a/packages/forms/docs/03-select.md +++ b/packages/forms/docs/03-select.md @@ -95,6 +95,8 @@ Select::make('author_id') ->getOptionLabelUsing(fn ($value): ?string => User::find($value)?->name), ``` +`getOptionLabelUsing()` is crucial, since it provides Filament with the label of the selected option, so it doesn't need to execute a full search to find it. If an option is not valid, it should return `null`. + You can inject various utilities into these functions as parameters. ### Setting a custom loading message @@ -242,6 +244,8 @@ Select::make('technologies') ->all()), ``` +`getOptionLabelsUsing()` is crucial, since it provides Filament with the labels of already-selected options, so it doesn't need to execute a full search to find them. It is also used to [validate](#valid-options-validation-in-rule) that the options that the user has selected are valid. If an option is not valid, it should not be present in the array returned by `getOptionLabelsUsing()`. + The `getOptionLabelsUsing()` method can inject various utilities into the function as parameters. ## Grouping options @@ -650,24 +654,6 @@ Select::make('status') You can inject various utilities into the function as parameters. -If you want to retrieve the options that have not been disabled, e.g. for validation purposes, you can do so using `getEnabledOptions()`: - -```php -use Filament\Forms\Components\Select; - -Select::make('status') - ->options([ - 'draft' => 'Draft', - 'reviewing' => 'Reviewing', - 'published' => 'Published', - ]) - ->default('draft') - ->disableOptionWhen(fn (string $value): bool => $value === 'published') - ->in(fn (Select $component): array => array_keys($component->getEnabledOptions())) -``` - -For more information about the `in()` function, please see the [Validation documentation](validation#in). - ## Adding affix text aside the field You may place text before and after the input using the `prefix()` and `suffix()` methods: @@ -863,7 +849,55 @@ ModalTableSelect::make('category_id') As well as all rules listed on the [validation](validation) page, there are additional rules that are specific to selects. -### Selected items validation +### Valid options validation (`in()` rule) + +The [`in()`](validation#in) rule ensures that users cannot select an option that is not in the list of options. This is an important rule for data integrity purposes, so Filament applies it by default to all select fields. + + + +Since there are many ways for a select field to populate its options, and in many cases the options are not all loaded into the select by default and require searching to retrieve them, Filament uses the presence of a valid "option label" to determine whether the selected value exists. It also checks if that option is [disabled](#disabling-specific-options) or not. + +If you are using a custom search query to retrieve options, you should ensure that the `getOptionLabelUsing()` method is defined, so that Filament can validate the selected value against the available options: + +```php +use Filament\Forms\Components\Select; + +Select::make('author_id') + ->searchable() + ->getSearchResultsUsing(fn (string $search): array => Author::query() + ->where('name', 'like', "%{$search}%") + ->limit(50) + ->pluck('name', 'id') + ->all()) + ->getOptionLabelUsing(fn (string $value): ?string => Author::find($value)?->name), +``` + +The `getOptionLabelUsing()` method should return `null` if the option is not valid, to allow Filament to determine that the selected value is not in the list of options. If the option is valid, it should return the label of the option. + +If you are using a `multiple()` select or multi-select, you should define `getOptionLabelsUsing()` instead of `getOptionLabelUsing()`. `$values` will be passed into the callback instead of `$value`, and you should return a `$key => $value` array of labels and their corresponding values: + +```php +use Filament\Forms\Components\Select; + +Select::make('technologies') + ->multiple() + ->searchable() + ->getSearchResultsUsing(fn (string $search): array => Technology::query() + ->where('name', 'like', "%{$search}%") + ->limit(50) + ->pluck('name', 'id') + ->all()) + ->getOptionLabelsUsing(fn (array $values): array => Technology::query() + ->whereIn('id', $values) + ->pluck('name', 'id') + ->all()), +``` + +If you are using the `relationship()` method, the `getOptionLabelUsing()` or `getOptionLabelsUsing()` methods will be automatically defined for you, so you don't need to worry about them. + +### Number of selected items validation You can validate the minimum and maximum number of items that you can select in a [multi-select](#multi-select) by setting the `minItems()` and `maxItems()` methods: diff --git a/packages/forms/docs/23-validation.md b/packages/forms/docs/23-validation.md index 4c079f7899..66ad5b4015 100644 --- a/packages/forms/docs/23-validation.md +++ b/packages/forms/docs/23-validation.md @@ -259,12 +259,15 @@ Field::make('color')->hexColor() ``` ### In + The field must be included in the given list of values. [See the Laravel documentation.](https://laravel.com/docs/validation#rule-in) ```php Field::make('status')->in(['pending', 'completed']) ``` +The [toggle buttons](toggle-buttons), [checkbox list](checkbox-list), [radio](radio) and [select](select#valid-options-validation-in-rule) fields automatically apply the `in()` rule based on their available options, so you do not need to add it manually. + ### Ip Address The field must be an IP address. [See the Laravel documentation.](https://laravel.com/docs/validation#rule-ip) diff --git a/packages/forms/src/Components/Concerns/CanBeValidated.php b/packages/forms/src/Components/Concerns/CanBeValidated.php index d6ef9d41b2..fecb6db1a2 100644 --- a/packages/forms/src/Components/Concerns/CanBeValidated.php +++ b/packages/forms/src/Components/Concerns/CanBeValidated.php @@ -319,7 +319,7 @@ trait CanBeValidated } if (is_string($values)) { - $values = array_map('trim', explode(',', $values)); + $values = array_map(trim(...), explode(',', $values)); } return Rule::notIn($values); @@ -822,7 +822,7 @@ trait CanBeValidated } if (is_string($values)) { - $values = array_map('trim', explode(',', $values)); + $values = array_map(trim(...), explode(',', $values)); } return $values; diff --git a/packages/forms/src/Components/Concerns/CanDisableOptions.php b/packages/forms/src/Components/Concerns/CanDisableOptions.php index 75e525379a..c059da9266 100644 --- a/packages/forms/src/Components/Concerns/CanDisableOptions.php +++ b/packages/forms/src/Components/Concerns/CanDisableOptions.php @@ -42,7 +42,10 @@ trait CanDisableOptions return $carry->put($value, $label); }, collect()) - ->filter(fn ($label, $value) => ! $this->isOptionDisabled($value, $label)) + ->when( + $this->hasDisabledOptions(), + fn (Collection $options): Collection => $options->filter(fn ($label, $value) => ! $this->isOptionDisabled($value, $label)), + ) ->all(); } @@ -51,16 +54,37 @@ trait CanDisableOptions */ public function isOptionDisabled($value, string | Htmlable $label): bool { - return collect($this->isOptionDisabled) - ->contains(fn (bool | Closure $isOptionDisabled): bool => (bool) $this->evaluate($isOptionDisabled, [ + foreach ($this->isOptionDisabled as $isOptionDisabled) { + if ($this->evaluate($isOptionDisabled, [ 'label' => $label, 'value' => $value, - ])); + ])) { + return true; + } + } + + return false; + } + + public function hasDisabledOptions(): bool + { + foreach ($this->isOptionDisabled as $isOptionDisabled) { + if ($isOptionDisabled !== false) { + return true; + } + } + + return false; } public function hasDynamicDisabledOptions(): bool { - return collect($this->isOptionDisabled) - ->contains(fn (bool | Closure $isOptionDisabled): bool => $isOptionDisabled instanceof Closure); + foreach ($this->isOptionDisabled as $isOptionDisabled) { + if ($isOptionDisabled instanceof Closure) { + return true; + } + } + + return false; } } diff --git a/packages/forms/src/Components/Select.php b/packages/forms/src/Components/Select.php index db3c391068..cbc445ae4d 100644 --- a/packages/forms/src/Components/Select.php +++ b/packages/forms/src/Components/Select.php @@ -95,9 +95,9 @@ class Select extends Field implements Contracts\CanDisableOptions, Contracts\Has protected bool | Closure $isMultiple = false; - protected ?Closure $getOptionLabelUsing; + protected ?Closure $getOptionLabelUsing = null; - protected ?Closure $getOptionLabelsUsing; + protected ?Closure $getOptionLabelsUsing = null; protected ?Closure $getSearchResultsUsing = null; @@ -142,62 +142,6 @@ class Select extends Field implements Contracts\CanDisableOptions, Contracts\Has $component->state([]); }); - $this->getOptionLabelUsing(static function (Select $component, $value): ?string { - $options = $component->getOptions(); - - foreach ($options as $groupedOptions) { - if (! is_array($groupedOptions)) { - continue; - } - - if (! array_key_exists($value, $groupedOptions)) { - continue; - } - - return $groupedOptions[$value]; - } - - if ($value instanceof BackedEnum) { - $value = $value->value; - } - - if (! array_key_exists($value, $options)) { - return null; - } - - return $options[$value]; - }); - - $this->getOptionLabelsUsing(static function (Select $component, array $values): array { - $options = $component->getOptions(); - - $labels = []; - - foreach ($values as $value) { - if ($value instanceof BackedEnum) { - $value = $value->value; - } - - foreach ($options as $groupedOptions) { - if (! is_array($groupedOptions)) { - continue; - } - - if (! array_key_exists($value, $groupedOptions)) { - continue; - } - - $labels[$value] = $groupedOptions[$value]; - - continue 2; - } - - $labels[$value] = $options[$value] ?? null; - } - - return $labels; - }); - $this->transformOptionsForJsUsing(static function (Select $component, array $options): array { return collect($options) ->map(fn ($label, $value): array => is_array($label) @@ -602,6 +546,37 @@ class Select extends Field implements Contracts\CanDisableOptions, Contracts\Has #[Renderless] public function getOptionLabel(bool $withDefault = true): ?string { + if (! $this->getOptionLabelUsing) { + $state = $this->getState(); + $options = $this->getOptions(); + + if ($state instanceof BackedEnum) { + $state = $state->value; + } + + foreach ($options as $groupedOptions) { + if (! is_array($groupedOptions)) { + continue; + } + + if (blank($groupedOptions[$state] ?? null)) { + continue; + } + + return $groupedOptions[$state]; + } + + if (filled($options[$state] ?? null) && (! is_array($options[$state]))) { + return $options[$state]; + } + + if ($withDefault) { + return $state; + } + + return null; + } + $state = null; $label = $this->evaluate($this->getOptionLabelUsing, [ @@ -622,6 +597,48 @@ class Select extends Field implements Contracts\CanDisableOptions, Contracts\Has */ public function getOptionLabels(bool $withDefaults = true): array { + if (! $this->getOptionLabelsUsing) { + $state = $this->getState(); + $options = $this->getOptions(); + + $labels = []; + + foreach ($state as $value) { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + foreach ($options as $groupedOptions) { + if (! is_array($groupedOptions)) { + continue; + } + + if (blank($groupedOptions[$value] ?? null)) { + continue; + } + + $labels[$value] = $groupedOptions[$value]; + + continue 2; + } + + if ( + filled($options[$value] ?? null) + && (! is_array($options[$value])) + ) { + $labels[$value] = $options[$value]; + + continue; + } + + if ($withDefaults) { + $labels[$value] = $value; + } + } + + return $labels; + } + $labels = $this->evaluate($this->getOptionLabelsUsing, [ 'values' => fn (): array => $this->getState(), ]); @@ -1412,10 +1429,58 @@ class Select extends Field implements Contracts\CanDisableOptions, Contracts\Has } if ($this->isMultiple()) { - return array_keys($this->getOptionLabels(withDefaults: false)); + if ((! $this->getOptionLabelsUsing) && (! $this->options)) { + throw new Exception("Filament failed to validate the [{$this->getStatePath()}] field\'s selected options because it did not have an [options()] or [getOptionLabelsUsing()] configuration. Please use one of these methods to inform Filament which options are valid for this field."); + } + + $state = $this->getState(); + $optionLabels = $this->getOptionLabels(withDefaults: false); + + if (count($state) > count($optionLabels)) { + return []; + } + + $state = array_map( + static fn (mixed $value): mixed => ($value instanceof BackedEnum) ? $value->value : $value, + $state, + ); + + if (count(array_diff($state, array_keys($optionLabels)))) { + return []; + } + + if ($this->hasDisabledOptions()) { + foreach ($optionLabels as $optionValue => $optionLabel) { + if ($this->isOptionDisabled($optionValue, $optionLabel)) { + return []; + } + } + } + + return null; } - return blank($this->getOptionLabel(withDefault: false)) ? [] : null; + if ((! $this->getOptionLabelUsing) && (! $this->options)) { + throw new Exception("Filament failed to validate the [{$this->getStatePath()}] field\'s selected options because it did not have an [options()] or [getOptionLabelUsing()] configuration. Please use one of these methods to inform Filament which options are valid for this field."); + } + + $optionLabel = $this->getOptionLabel(withDefault: false); + + if (blank($optionLabel)) { + return []; + } + + $state = $this->getState(); + + if ($state instanceof BackedEnum) { + $state = $state->value; + } + + if ($this->hasDisabledOptions() && $this->isOptionDisabled($state, $optionLabel)) { + return []; + } + + return null; } public function hasInValidationOnMultipleValues(): bool diff --git a/tests/src/Forms/Components/SelectTest.php b/tests/src/Forms/Components/SelectTest.php new file mode 100644 index 0000000000..427121908f --- /dev/null +++ b/tests/src/Forms/Components/SelectTest.php @@ -0,0 +1,176 @@ +fillForm(['number' => 'one']) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithSelect::class) + ->fillForm(['number' => 'four']) + ->call('save') + ->assertHasFormErrors(['number' => ['in']]); +}); + +it('can automatically validate valid multiple options', function (): void { + livewire(TestComponentWithMultipleSelect::class) + ->fillForm(['number' => ['one', 'two']]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithMultipleSelect::class) + ->fillForm(['number' => ['one', 'four']]) + ->call('save') + ->assertHasFormErrors(['number.1' => ['in']]); +}); + +it('can automatically validate valid options with custom search results', function (): void { + livewire(TestComponentWithSelectCustomSearchResults::class) + ->fillForm(['number' => 'one']) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithSelectCustomSearchResults::class) + ->fillForm(['number' => 'four']) + ->call('save') + ->assertHasFormErrors(['number' => ['in']]); +}); + +it('can automatically validate valid multiple options with custom search results', function (): void { + livewire(TestComponentWithMultipleSelectCustomSearchResults::class) + ->fillForm(['number' => ['one', 'two']]) + ->call('save') + ->assertHasNoFormErrors(); + + livewire(TestComponentWithMultipleSelectCustomSearchResults::class) + ->fillForm(['number' => ['one', 'four']]) + ->call('save') + ->assertHasFormErrors(['number.1' => ['in']]); +}); + +class TestComponentWithSelect extends Livewire +{ + public $data = []; + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('number') + ->options([ + 'one' => 'One', + 'two' => 'Two', + 'three' => 'Three', + ]), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +} + +class TestComponentWithMultipleSelect extends Livewire +{ + public $data = []; + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('number') + ->options([ + 'one' => 'One', + 'two' => 'Two', + 'three' => 'Three', + ]) + ->multiple(), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +} + +class TestComponentWithSelectCustomSearchResults extends Livewire +{ + public $data = []; + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('number') + ->getSearchResultsUsing(fn (string $search) => collect([ + 'one' => 'One', + 'two' => 'Two', + 'three' => 'Three', + ])->filter(fn (string $label, string $value): bool => str_contains($label, $search) || str_contains($value, $search))) + ->getOptionLabelUsing(fn (string $value): ?string => match ($value) { + 'one' => 'One', + 'two' => 'Two', + 'three' => 'Three', + default => null, + }), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +} + +class TestComponentWithMultipleSelectCustomSearchResults extends Livewire +{ + public $data = []; + + public function form(Schema $form): Schema + { + return $form + ->schema([ + Select::make('number') + ->getSearchResultsUsing(fn (string $search) => collect([ + 'one' => 'One', + 'two' => 'Two', + 'three' => 'Three', + ])->filter(fn (string $label, string $value): bool => str_contains($label, $search) || str_contains($value, $search))) + ->getOptionLabelsUsing(function (array $values): array { + $labels = []; + + foreach ($values as $value) { + $labels[$value] = match ($value) { + 'one' => 'One', + 'two' => 'Two', + 'three' => 'Three', + default => null, + }; + } + + return $labels; + }) + ->multiple(), + ]) + ->statePath('data'); + } + + public function save(): void + { + $this->form->getState(); + } +}