Merge pull request #16920 from filamentphp/refactor-select-option-validation

Refactor select option validation
This commit is contained in:
Dan Harrin
2025-07-11 14:12:42 +01:00
committed by GitHub
6 changed files with 389 additions and 87 deletions
+53 -19
View File
@@ -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`.
<UtilityInjection set="formFields" version="4.x" extras="Option value;;mixed;;$value;;[<code>getOptionLabelUsing()</code> only] The option value to retrieve the label for.||Option values;;array<mixed>;;$values;;[<code>getOptionLabelsUsing()</code> only] The option values to retrieve the labels for.||Search;;?string;;$search;;[<code>getSearchResultsUsing()</code> only] The current search input value, if the field is searchable.">You can inject various utilities into these functions as parameters.</UtilityInjection>
### 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()`.
<UtilityInjection set="formFields" version="4.x" extras="Option values;;array<mixed>;;$values;;[<code>getOptionLabelsUsing()</code> only] The option values to retrieve the labels for.">The `getOptionLabelsUsing()` method can inject various utilities into the function as parameters.</UtilityInjection>
## Grouping options
@@ -650,24 +654,6 @@ Select::make('status')
<UtilityInjection set="formFields" version="4.x" extras="Option value;;mixed;;$value;;The value of the option to disable.||Option label;;string | Illuminate\Contracts\Support\Htmlable;;$label;;The label of the option to disable.">You can inject various utilities into the function as parameters.</UtilityInjection>
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.
<Aside variant="warning">
Selected option validation is crucial, so we strongly suggest that you [write automated tests](../testing/testing-schemas#testing-form-validation) for your forms to ensure that the validation works as expected.
</Aside>
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:
+3
View File
@@ -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)
@@ -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;
@@ -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;
}
}
+125 -60
View File
@@ -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
+176
View File
@@ -0,0 +1,176 @@
<?php
use Filament\Forms\Components\Select;
use Filament\Schemas\Schema;
use Filament\Tests\Fixtures\Livewire\Livewire;
use Filament\Tests\TestCase;
use function Filament\Tests\livewire;
uses(TestCase::class);
it('can automatically validate valid options', function (): void {
livewire(TestComponentWithSelect::class)
->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();
}
}