Adding distinct() method to fields

This commit is contained in:
Hugh Messenger
2023-09-20 23:54:48 -05:00
parent 4807d71184
commit 3238a9302e
15 changed files with 331 additions and 6 deletions
@@ -407,6 +407,46 @@ Select::make('status')
->in(fn (Select $component): array => array_keys($component->getEnabledOptions()))
```
## Distinct selection in a Repeater
You may enforce selection of any given option in only one instance of a Repeater by using the `distinct()` method.
This example of creating teams for a tournament in a Repeater would only allow each shirt color to be chosen once:
```php
use Filament\Forms\Components\Repeaater;
use Filament\Forms\Components\Select;
Repeaater::make('teams')
->maxItems(5)
->schema([
Select::make('shirt_color')
->options([
'blue' => 'Blue',
'green' => 'Green',
'red' => 'Red',
'yellow' => 'Yellow',
'white' => 'White',
])
->distinct(),
//
])
```
With no arguments, this will allow selection of the same color again, but will de-select the other occurrence of it.
Alternatively, you may specify the `shouldDisableOptions` argument, which will disable any selected option in all other repeater instances, so once an option is selected in one instance, it cannot be selected in another. **Note** that this may impact performance for Selects with a lot of options.
```php
Select::make('shirt_color')
->options([
//
])
->distinct(shouldDisableOptions: true)
```
The `distinct()` method works for single and multiple selects, with and without `shouldDisableOptions`. Without the `shouldDisableOptions` argument, the `live()` method is automatically applied.
## Adding affix text aside the field
You may place text before and after the input using the `prefix()` and `suffix()` methods:
@@ -54,6 +54,28 @@ Checkbox::make('is_admin')->inline(false)
<AutoScreenshot name="forms/fields/checkbox/not-inline" alt="Checkbox with its label above" version="3.x" />
## Distinct selection in a Repeater
You may enforce selection of a checkbox in one (and only one) instance of a Repeater by using the `distinct()` method.
This example of adding multiple locations in a Repeater would require that one (and only one) instance is selected as primary:
```php
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Repeaater;
Repeaater::make('locations')
->schema([
Checkbox::make('primary')
->distinct(),
//
])
```
Selecting the Primary checkbox in a second or subsequent repeat instance would de-select the previous selection.
A validation is automatically added which will fail if no instance of the checkbox is selected, or if more than one is selected (which of course shouldn't happen with newly created data).
## Checkbox validation
As well as all rules listed on the [validation](../validation) page, there are additional rules that are specific to checkboxes.
@@ -86,6 +86,28 @@ Toggle::make('is_admin')
<AutoScreenshot name="forms/fields/toggle/not-inline" alt="Toggle with its label above" version="3.x" />
## Distinct selection in a Repeater
You may enforce selection of a Toggle in one (and only one) instance of a Repeater by using the `distinct()` method.
This example of adding multiple locations in a Repeater would require that one (and only one) instance is selected as primary:
```php
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Repeaater;
Repeaater::make('locations')
->schema([
Toggle::make('primary')
->distinct(),
//
])
```
Selecting the Primary toggle in a second or subsequent repeat instance would de-select the previous selection.
A validation is automatically added which will fail if no instance of the toggle is selected, or if more than one is selected (which of course shouldn't happen with newly created data).
## Toggle validation
As well as all rules listed on the [validation](../validation) page, there are additional rules that are specific to toggles.
@@ -132,6 +132,33 @@ CheckboxList::make('technologies')
->in(fn (CheckboxList $component): array => array_keys($component->getEnabledOptions()))
```
## Distinct selection in a Repeater
You may enforce selection of any given checkbox in only one instance of a Repeater by using the `distinct()` method.
This example of assigning pets to rooms in your house would only allow each pet to be assigned to one room:
```php
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Repeaater;
Repeaater::make('rooms')
->schema([
CheckboxList::make('pets')
->options([
'della' => 'Della',
'leslie' => 'Leslie',
'rosie' => 'Rosie',
'gracie' => 'Gracie',
'mocheese' => 'MoCheese',
])
->distinct(),
//
])
```
If any checkbox is selected again in a subsequent repeat instance, the previous selection will be de-selected.
## Searching options
You may enable a search input to allow easier access to many options, using the `searchable()` method:
+28
View File
@@ -101,3 +101,31 @@ Radio::make('status')
->disableOptionWhen(fn (string $value): bool => $value === 'published')
->in(fn (Radio $component): array => array_keys($component->getEnabledOptions()))
```
## Distinct selection in a Repeater
You may enforce selection of any given radio button in only one instance of a Repeater by using the `distinct()` method.
This example of creating teams for a tournament in a Repeater would only allow each shirt color to be chosen once
```php
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Repeaater;
Repeaater::make('teams')
->maxItems(5)
->schema([
Radio::make('shirt_color')
->options([
'blue' => 'Blue',
'green' => 'Green',
'red' => 'Red',
'yellow' => 'Yellow',
'white' => 'White',
])
->distinct(),
//
])
```
If any radio button is selected again in a subsequent repeat instance, the previous selection will be de-selected.
@@ -460,6 +460,11 @@ You are trying to retrieve the value of `client_id` from inside the repeater ite
You can use `../` to go up a level in the data structure, so `$get('../client_id')` is `$get('repeater.client_id')` and `$get('../../client_id')` is `$get('client_id')`.
## Distinct selection in a Repeater
Fields which provide selection of predefined options like Select, Radio, etc. provide a `distinct()` method, which enforces only allowing each option to be selected in one instance of a Repeater.
This functionality is documented in [Select](03-select.md#distinct-selection-in-a-repeater), [Checkbox](04-checkbox.md#distinct-selection-in-a-repeater), [CheckboxList](06-checkbox-list.md#distinct-selection-in-a-repeater), [Toggle](05-toggle.md#distinct-selection-in-a-repeater) and [Radio](07-radio.md#distinct-selection-in-a-repeater).
## Repeater validation
@@ -5,6 +5,7 @@ namespace Filament\Forms\Components;
class Checkbox extends Field
{
use Concerns\CanBeAccepted;
use Concerns\CanBeDistinct;
use Concerns\CanBeInline;
use Concerns\HasExtraInputAttributes;
@@ -17,6 +17,7 @@ use Illuminate\Support\Str;
class CheckboxList extends Field implements Contracts\HasNestedRecursiveValidationRules
{
use Concerns\CanBeSearchable;
use Concerns\CanBeDistinct;
use Concerns\CanDisableOptions;
use Concerns\HasExtraInputAttributes;
use Concerns\HasGridDirection;
@@ -13,6 +13,7 @@ class Component extends ViewComponent
use Concerns\BelongsToContainer;
use Concerns\BelongsToModel;
use Concerns\CanBeConcealed;
use Concerns\CanBeRepeated;
use Concerns\CanBeDisabled;
use Concerns\CanBeHidden;
use Concerns\CanSpanColumns;
@@ -0,0 +1,142 @@
<?php
namespace Filament\Forms\Components\Concerns;
use Closure;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Select;
use Filament\Forms\Set;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
trait CanBeDistinct
{
protected bool | Closure $isDistinct = false;
public function distinct(bool | Closure $condition = true, bool $shouldDisableOptions = false): static
{
$this->isDistinct = $condition;
// In shouldDisableOptions mode, any option(s) selected in one instance of the field will be
// disabled in all other instances, using disableOptionWhen(). This really only makes sense for
// Select, and not for Radio, Toggle or Checkbox. So in this code, Select can work in either mode,
// the rest will ignore shouldDisableOptions.
//
// When not in shouldDisableOptions mode, any option(s) selected in one instance of the field
// will be de-selected in all other instances. This requires setting the field to be live(), and
// handling state with afterStateUpdated() and $set.
if ($this instanceof Select && $shouldDisableOptions) {
$this->disableOptionWhen(static function (Component $component, string $value, mixed $state) {
if (! $component->isRepeated()) {
return false;
}
// collect the state of all other instances of this field, and check if this value already
// exists. Using flatten() and wrap() means this will work for single or multiple select.
return collect($component->getRepeaterComponent()?->getState())
->pluck($component->getName())
->flatten()
->diff(Arr::wrap($state))
->filter()
->contains($value);
});
} else {
$this->live();
$this->afterStateUpdated(static function (Component $component, mixed $state, Set $set) {
$repeaterPath = $component->getRepeaterComponent()?->getStatePath();
$repeatKey = (string) Str::of($component->getStatePath())
->beforeLast(".{$component->getName()}")
->afterLast('.');
$siblings = collect($component->getRepeaterComponent()?->getState())
->reject(fn (array $item, string $key): bool => $key === $repeatKey);
if (is_array($state)) {
// get the repeat keys of any other instances of this field with options that intersect with $state and
// set them to the diff (so remove the intersections)
$siblings->filter(fn (array $item): bool => ! empty(array_intersect(data_get($item, $component->getName(), []), $state)))
->mapWithKeys(fn (array $item, string $key): array => [$key => array_values(array_diff(data_get($item, $component->getName(), []), $state))])
->each(function (array $newState, string $key) use ($set, $repeaterPath, $component) {
$set(path: "{$repeaterPath}.{$key}.{$component->getName()}", state: $newState, isAbsolute: true);
});
} else {
// get the repeat keys of any other instances of this field with the same $state and set them to null.
// @TODO figure out if we need to make the 'null' value configurable, like should it be 'false' for
// Toggles, or use a specified default(), etc?
$siblings->filter(fn (array $item): bool => data_get($item, $component->getName()) === $state)
->map(fn (array $item, string $key): string => $key)
->each(function (string $key) use ($set, $repeaterPath, $component) {
$set(path: "{$repeaterPath}.{$key}.{$component->getName()}", state: null, isAbsolute: true);
});
}
});
}
$this->rule(static function (Component $component, mixed $state) {
return function (string $attribute, $value, Closure $fail) use ($component, $state) {
if (! $component->isRepeated()) {
return;
}
$repeatKey = (string) Str::of($component->getStatePath())
->beforeLast(".{$component->getName()}")
->afterLast('.');
$siblings = collect($component->getRepeaterComponent()?->getState())
->reject(fn (array $item, string $key): bool => $key === $repeatKey);
if (count($siblings) === 0) {
return;
}
if (is_bool($state)) {
// if it's boolean, it's a Toggle or Checkbox, so "one, and only one, must be selected"
$selected = collect($siblings)
->pluck($component->getName())
->contains(true);
if (empty($selected)) {
$fail("One of {$component->getLabel()} must be selected.");
return;
}
if ($value) {
$isDuplicate = $siblings->pluck($component->getName())
->contains(true);
if ($isDuplicate) {
$fail("Only one of {$component->getLabel()} may be selected.");
}
}
} elseif (is_array($state)) {
// an array is Select multiple() or CheckboxList, so test for any intersection with other instances
$intersects = $siblings->filter(fn (array $item): bool => ! empty(array_intersect(data_get($item, $component->getName(), []), $state)))
->isNotEmpty();
if ($intersects) {
$fail("{$component->getLabel()} field must be distinct.");
}
} else {
// it's a single select of some sort, so test for duplicates
$isDuplicate = $siblings->pluck($component->getName())
->contains($state);
if ($isDuplicate) {
$fail("{$component->getLabel()} field must be distinct.");
}
}
};
});
return $this;
}
public function isDistinct(): bool
{
return (bool) $this->evaluate($this->isDistinct);
}
}
@@ -0,0 +1,35 @@
<?php
namespace Filament\Forms\Components\Concerns;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Repeater;
trait CanBeRepeated
{
protected Component | bool | null $cachedRepeaterComponent = null;
public function getRepeaterComponent(): ?Component
{
if (filled($this->cachedRepeaterComponent)) {
return $this->cachedRepeaterComponent ?: null;
}
$parentComponent = $this->getContainer()->getParentComponent();
if (! $parentComponent) {
$this->cachedRepeaterComponent = false;
} elseif ($parentComponent instanceof Repeater) {
$this->cachedRepeaterComponent = $parentComponent;
} else {
$this->cachedRepeaterComponent = $parentComponent->getRepeaterComponent();
}
return $this->cachedRepeaterComponent ?: null;
}
public function isRepeated(): bool
{
return (bool) $this->getRepeaterComponent();
}
}
+1 -5
View File
@@ -8,6 +8,7 @@ use Illuminate\Contracts\Support\Htmlable;
class Radio extends Field
{
use Concerns\CanBeDistinct;
use Concerns\HasExtraInputAttributes;
use Concerns\HasGridDirection;
use Concerns\HasOptions;
@@ -26,11 +27,6 @@ class Radio extends Field
protected bool | Closure | null $isOptionDisabled = null;
protected function setUp(): void
{
parent::setUp();
}
public function boolean(string $trueLabel = 'Yes', string $falseLabel = 'No'): static
{
$this->options([
+5
View File
@@ -29,6 +29,7 @@ class Select extends Field implements Contracts\HasAffixActions, Contracts\HasNe
use Concerns\CanAllowHtml;
use Concerns\CanBePreloaded;
use Concerns\CanBeSearchable;
use Concerns\CanBeDistinct;
use Concerns\CanDisableOptions;
use Concerns\CanSelectPlaceholder;
use Concerns\CanLimitItemsLength;
@@ -1182,6 +1183,10 @@ class Select extends Field implements Contracts\HasAffixActions, Contracts\HasNe
public function hasDynamicOptions(): bool
{
if ($this->isDistinct()) {
return true;
}
if ($this->hasRelationship()) {
return $this->isPreloaded();
}
+1
View File
@@ -7,6 +7,7 @@ use Filament\Support\Concerns\HasExtraAlpineAttributes;
class Toggle extends Field
{
use Concerns\CanBeAccepted;
use Concerns\CanBeDistinct;
use Concerns\CanBeInline;
use Concerns\HasToggleColors;
use Concerns\HasToggleIcons;
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\Relation;
use function Livewire\invade;
trait HasQuery