Files
filament/packages/forms/src/Components/Select.php
T

1770 lines
57 KiB
PHP

<?php
namespace Filament\Forms\Components;
use BackedEnum;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\View\FormsIconAlias;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Contracts\HasAffixActions;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Filament\Schemas\Components\StateCasts\Contracts\StateCast;
use Filament\Schemas\Components\StateCasts\EnumArrayStateCast;
use Filament\Schemas\Components\StateCasts\EnumStateCast;
use Filament\Schemas\Components\StateCasts\OptionsArrayStateCast;
use Filament\Schemas\Components\StateCasts\OptionStateCast;
use Filament\Schemas\Schema;
use Filament\Support\Components\Attributes\ExposedLivewireMethod;
use Filament\Support\Concerns\HasExtraAlpineAttributes;
use Filament\Support\Facades\FilamentIcon;
use Filament\Support\Icons\Heroicon;
use Filament\Support\Services\RelationshipJoiner;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneOrMany;
use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Livewire\Attributes\Renderless;
use LogicException;
use Znck\Eloquent\Relations\BelongsToThrough;
use function Filament\Support\generate_search_column_expression;
use function Filament\Support\generate_search_term_expression;
class Select extends Field implements Contracts\CanDisableOptions, Contracts\HasNestedRecursiveValidationRules, HasAffixActions
{
use Concerns\CanAllowHtml;
use Concerns\CanBeNative;
use Concerns\CanBePreloaded;
use Concerns\CanBeSearchable;
use Concerns\CanDisableOptions;
use Concerns\CanDisableOptionsWhenSelectedInSiblingRepeaterItems;
use Concerns\CanFixIndistinctState;
use Concerns\CanLimitItemsLength;
use Concerns\CanSelectPlaceholder;
use Concerns\HasAffixes;
use Concerns\HasExtraInputAttributes;
use Concerns\HasLoadingMessage;
use Concerns\HasNestedRecursiveValidationRules;
use Concerns\HasOptions;
use Concerns\HasPivotData;
use Concerns\HasPlaceholder;
use HasExtraAlpineAttributes;
/**
* @var view-string
*/
protected string $view = 'filament-forms::components.select';
/**
* @var array<Component | Action | ActionGroup> | Closure | null
*/
protected array | Closure | null $createOptionActionForm = null;
protected ?Closure $createOptionUsing = null;
protected string | Closure | null $createOptionModalHeading = null;
protected string | Closure | null $editOptionModalHeading = null;
protected ?Closure $modifyCreateOptionActionUsing = null;
protected ?Closure $modifyManageOptionActionsUsing = null;
/**
* @var array<Component | Action | ActionGroup> | Closure | null
*/
protected array | Closure | null $editOptionActionForm = null;
protected ?Closure $fillEditOptionActionFormUsing = null;
protected ?Closure $updateOptionUsing = null;
protected ?Closure $modifyEditOptionActionUsing = null;
protected ?Model $cachedSelectedRecord = null;
protected bool | Closure $isMultiple = false;
protected ?Closure $getOptionLabelUsing = null;
protected ?Closure $getOptionLabelsUsing = null;
protected ?Closure $getSearchResultsUsing = null;
protected ?Closure $getSelectedRecordUsing = null;
protected ?Closure $transformOptionsForJsUsing = null;
/**
* @var array<string> | null
*/
protected ?array $searchColumns = null;
protected string | Closure | null $maxItemsMessage = null;
protected string | Closure | null $relationshipTitleAttribute = null;
protected string | Closure | null $position = null;
protected ?Closure $getOptionLabelFromRecordUsing = null;
protected string | Closure | null $relationship = null;
protected int | Closure $optionsLimit = 50;
protected bool | Closure | null $isSearchForcedCaseInsensitive = null;
protected bool | Closure $canOptionLabelsWrap = true;
protected bool | Closure $isReorderable = false;
protected bool | Closure | null $hasDynamicOptions = null;
protected function setUp(): void
{
parent::setUp();
$this->transformOptionsForJsUsing(static function (Select $component, array $options): array {
return collect($options)
->map(fn ($label, $value): array => is_array($label)
? ['label' => $value, 'options' => $component->transformOptionsForJs($label)]
: ['label' => $label, 'value' => strval($value), 'isDisabled' => $component->isOptionDisabled($value, $label)])
->values()
->all();
});
$this->placeholder(static fn (Select $component): ?string => $component->isDisabled() ? null : __('filament-forms::components.select.placeholder'));
$this->suffixActions([
static fn (Select $component): ?Action => $component->getCreateOptionAction(),
static fn (Select $component): ?Action => $component->getEditOptionAction(),
]);
}
public function boolean(?string $trueLabel = null, ?string $falseLabel = null, ?string $placeholder = null): static
{
$this->options([
1 => $trueLabel ?? __('filament-forms::components.select.boolean.true'),
0 => $falseLabel ?? __('filament-forms::components.select.boolean.false'),
]);
$this->placeholder($placeholder ?? '-');
$this->stateCast(app(BooleanStateCast::class, ['isStoredAsInt' => true]));
return $this;
}
public function createOptionAction(?Closure $callback): static
{
$this->modifyCreateOptionActionUsing = $callback;
return $this;
}
public function manageOptionActions(?Closure $callback): static
{
$this->modifyManageOptionActionsUsing = $callback;
return $this;
}
/**
* @param array<Component | Action | ActionGroup> | Closure | null $schema
*/
public function manageOptionForm(array | Closure | null $schema): static
{
$this->createOptionForm($schema);
$this->editOptionForm($schema);
return $this;
}
/**
* @param array<Component | Action | ActionGroup> | Closure | null $schema
*/
public function createOptionForm(array | Closure | null $schema): static
{
$this->createOptionActionForm = $schema;
return $this;
}
public function createOptionUsing(?Closure $callback): static
{
$this->createOptionUsing = $callback;
return $this;
}
public function getCreateOptionUsing(): ?Closure
{
return $this->createOptionUsing;
}
public function getCreateOptionActionName(): string
{
return 'createOption';
}
public function getCreateOptionAction(): ?Action
{
if ($this->isDisabled()) {
return null;
}
if (! $this->hasCreateOptionActionFormSchema()) {
return null;
}
$action = Action::make($this->getCreateOptionActionName())
->label(__('filament-forms::components.select.actions.create_option.label'))
->schema(static function (Select $component, Schema $schema): array | Schema | null {
return $component->getCreateOptionActionForm($schema->model(
$component->hasRelationship() ? $component->getRelationship()->getModel()::class : $component->getActionSchemaModel(),
));
})
->action(static function (Action $action, array $arguments, Select $component, array $data, Schema $schema): void {
if (! $component->getCreateOptionUsing()) {
throw new LogicException("Select field [{$component->getStatePath()}] must have a [createOptionUsing()] closure set.");
}
$createdOptionKey = $component->evaluate($component->getCreateOptionUsing(), [
'data' => $data,
'form' => $schema,
'schema' => $schema,
]);
$state = $component->isMultiple()
? [
...$component->getState(),
$createdOptionKey,
]
: $createdOptionKey;
$component->state($state);
$component->callAfterStateUpdated();
$component->refreshSelectedOptionLabel();
if (! ($arguments['another'] ?? false)) {
return;
}
$action->callAfter();
$schema->fill();
$action->halt();
})
->color('gray')
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_SELECT_ACTIONS_CREATE_OPTION) ?? Heroicon::Plus)
->iconButton()
->modalHeading($this->getCreateOptionModalHeading() ?? __('filament-forms::components.select.actions.create_option.modal.heading'))
->modalSubmitActionLabel(__('filament-forms::components.select.actions.create_option.modal.actions.create.label'))
->extraModalFooterActions(fn (Action $action, Select $component): array => $component->isMultiple() ? [
$action->makeModalSubmitAction('createAnother', arguments: ['another' => true])
->label(__('filament-forms::components.select.actions.create_option.modal.actions.create_another.label')),
] : []);
if ($this->modifyManageOptionActionsUsing) {
$action = $this->evaluate($this->modifyManageOptionActionsUsing, [
'action' => $action,
]) ?? $action;
}
if ($this->modifyCreateOptionActionUsing) {
$action = $this->evaluate($this->modifyCreateOptionActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
public function createOptionModalHeading(string | Closure | null $heading): static
{
$this->createOptionModalHeading = $heading;
return $this;
}
public function editOptionModalHeading(string | Closure | null $heading): static
{
$this->editOptionModalHeading = $heading;
return $this;
}
public function editOptionAction(?Closure $callback): static
{
$this->modifyEditOptionActionUsing = $callback;
return $this;
}
/**
* @return array<Component | Action | ActionGroup> | Schema | null
*/
public function getCreateOptionActionForm(Schema $schema): array | Schema | null
{
return $this->evaluate($this->createOptionActionForm, ['form' => $schema, 'schema' => $schema]);
}
public function hasCreateOptionActionFormSchema(): bool
{
return (bool) $this->createOptionActionForm;
}
/**
* @return array<Component | Action | ActionGroup> | Schema | null
*/
public function getEditOptionActionForm(Schema $schema): array | Schema | null
{
return $this->evaluate($this->editOptionActionForm, ['form' => $schema, 'schema' => $schema]);
}
public function hasEditOptionActionFormSchema(): bool
{
return (bool) $this->editOptionActionForm;
}
/**
* @param array<Component | Action | ActionGroup> | Closure | null $schema
*/
public function editOptionForm(array | Closure | null $schema): static
{
$this->editOptionActionForm = $schema;
$this->live();
return $this;
}
public function updateOptionUsing(?Closure $callback): static
{
$this->updateOptionUsing = $callback;
return $this;
}
public function getUpdateOptionUsing(): ?Closure
{
return $this->updateOptionUsing;
}
public function getEditOptionActionName(): string
{
return 'editOption';
}
public function getEditOptionAction(): ?Action
{
if ($this->isDisabled()) {
return null;
}
if ($this->isMultiple()) {
return null;
}
if (! $this->hasEditOptionActionFormSchema()) {
return null;
}
$action = Action::make($this->getEditOptionActionName())
->label(__('filament-forms::components.select.actions.edit_option.label'))
->schema(static function (Select $component, Schema $schema): array | Schema | null {
return $component->getEditOptionActionForm(
$schema->model($component->getSelectedRecord()),
);
})
->fillForm(static fn (Select $component): ?array => $component->getEditOptionActionFormData())
->action(static function (Action $action, array $arguments, Select $component, array $data, Schema $schema): void {
if (! $component->getUpdateOptionUsing()) {
throw new LogicException("Select field [{$component->getStatePath()}] must have a [updateOptionUsing()] closure set.");
}
$component->evaluate($component->getUpdateOptionUsing(), [
'data' => $data,
'form' => $schema,
'schema' => $schema,
]);
$component->refreshSelectedOptionLabel();
})
->color('gray')
->icon(FilamentIcon::resolve(FormsIconAlias::COMPONENTS_SELECT_ACTIONS_EDIT_OPTION) ?? Heroicon::PencilSquare)
->iconButton()
->modalHeading($this->getEditOptionModalHeading() ?? __('filament-forms::components.select.actions.edit_option.modal.heading'))
->modalSubmitActionLabel(__('filament-forms::components.select.actions.edit_option.modal.actions.save.label'))
->visible(fn (): bool => filled($this->getState()));
if ($this->modifyManageOptionActionsUsing) {
$action = $this->evaluate($this->modifyManageOptionActionsUsing, [
'action' => $action,
]) ?? $action;
}
if ($this->modifyEditOptionActionUsing) {
$action = $this->evaluate($this->modifyEditOptionActionUsing, [
'action' => $action,
]) ?? $action;
}
return $action;
}
/**
* @return ?array<string, mixed>
*/
public function getEditOptionActionFormData(): ?array
{
return $this->evaluate($this->fillEditOptionActionFormUsing);
}
public function fillEditOptionActionFormUsing(?Closure $callback): static
{
$this->fillEditOptionActionFormUsing = $callback;
return $this;
}
public function getCreateOptionModalHeading(): ?string
{
return $this->evaluate($this->createOptionModalHeading);
}
public function getEditOptionModalHeading(): ?string
{
return $this->evaluate($this->editOptionModalHeading);
}
public function getOptionLabelUsing(?Closure $callback): static
{
$this->getOptionLabelUsing = $callback;
return $this;
}
public function getSelectedRecordUsing(?Closure $callback): static
{
$this->getSelectedRecordUsing = $callback;
return $this;
}
public function getOptionLabelsUsing(?Closure $callback): static
{
$this->getOptionLabelsUsing = $callback;
return $this;
}
public function getSearchResultsUsing(?Closure $callback): static
{
$this->getSearchResultsUsing = $callback;
return $this;
}
public function transformOptionsForJsUsing(?Closure $callback): static
{
$this->transformOptionsForJsUsing = $callback;
return $this;
}
/**
* @param bool | array<string> | Closure $condition
*/
public function searchable(bool | array | Closure $condition = true): static
{
if (is_array($condition)) {
$this->isSearchable = true;
$this->searchColumns = $condition;
} else {
$this->isSearchable = $condition;
$this->searchColumns = null;
}
return $this;
}
public function multiple(bool | Closure $condition = true): static
{
$this->isMultiple = $condition;
return $this;
}
public function reorderable(bool | Closure $condition = true): static
{
$this->isReorderable = $condition;
return $this;
}
public function isReorderable(): bool
{
return (bool) $this->evaluate($this->isReorderable);
}
public function position(string | Closure | null $position): static
{
$this->position = $position;
return $this;
}
public function maxItemsMessage(string | Closure | null $message): static
{
$this->maxItemsMessage = $message;
return $this;
}
public function optionsLimit(int | Closure $limit): static
{
$this->optionsLimit = $limit;
return $this;
}
public function getPosition(): ?string
{
return $this->evaluate($this->position);
}
#[ExposedLivewireMethod]
#[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, [
'value' => function () use (&$state): mixed {
return $state = $this->getState();
},
]);
if ($withDefault) {
$label ??= ($state ?? $this->getState());
}
return $label;
}
/**
* @return array<string>
*/
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(),
]);
if ($labels instanceof Arrayable) {
$labels = $labels->toArray();
}
foreach ($labels as $value => $label) {
if (filled($label)) {
continue;
}
if ($withDefaults) {
$labels[$value] = $value;
continue;
}
unset($labels[$value]);
}
return $labels;
}
/**
* @return array<string>
*/
public function getSearchColumns(): ?array
{
$columns = $this->searchColumns;
if ($this->hasRelationship() && (filled($relationshipTitleAttribute = $this->getRelationshipTitleAttribute()))) {
$columns ??= [$relationshipTitleAttribute];
}
return $columns;
}
/**
* @return array<string>
*/
public function getSearchResults(string $search): array
{
if (! $this->getSearchResultsUsing) {
return [];
}
$results = $this->evaluate($this->getSearchResultsUsing, [
'query' => $search,
'search' => $search,
'searchQuery' => $search,
]);
if ($results instanceof Arrayable) {
$results = $results->toArray();
}
return $results;
}
/**
* @return array<array{'label': string, 'value': string}>
*/
#[ExposedLivewireMethod]
#[Renderless]
public function getSearchResultsForJs(string $search): array
{
return $this->transformOptionsForJs($this->getSearchResults($search));
}
/**
* @return array<array{'label': string, 'value': string}>
*/
#[ExposedLivewireMethod]
#[Renderless]
public function getOptionsForJs(): array
{
return $this->transformOptionsForJs($this->getOptions());
}
/**
* @return array<array{'label': string, 'value': string}>
*/
#[ExposedLivewireMethod]
#[Renderless]
public function getOptionLabelsForJs(): array
{
return $this->transformOptionsForJs($this->getOptionLabels());
}
/**
* @param array<string | array<string>> $options
* @return array<array<string, mixed>>
*/
protected function transformOptionsForJs(array $options): array
{
if (empty($options)) {
return [];
}
$transformedOptions = $this->evaluate($this->transformOptionsForJsUsing, [
'options' => $options,
]);
if ($transformedOptions instanceof Arrayable) {
return $transformedOptions->toArray();
}
return $transformedOptions;
}
public function isMultiple(): bool
{
return (bool) $this->evaluate($this->isMultiple);
}
public function isSearchable(): bool
{
return $this->evaluate($this->isSearchable) ?? $this->isMultiple();
}
public function relationship(string | Closure | null $name = null, string | Closure | null $titleAttribute = null, ?Closure $modifyQueryUsing = null, bool $ignoreRecord = false): static
{
$this->relationship = $name ?? $this->getName();
$this->relationshipTitleAttribute = $titleAttribute;
$this->getSearchResultsUsing(static function (Select $component, ?string $search) use ($modifyQueryUsing, $ignoreRecord): array {
$relationship = Relation::noConstraints(fn () => $component->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
if ($ignoreRecord && ($record = $component->getRecord())) {
$relationshipQuery->where($record->getQualifiedKeyName(), '!=', $record->getKey());
}
if ($modifyQueryUsing) {
$relationshipQuery = $component->evaluate($modifyQueryUsing, [
'query' => $relationshipQuery,
'search' => $search,
]) ?? $relationshipQuery;
}
$component->applySearchConstraint(
$relationshipQuery,
generate_search_term_expression($search, $component->isSearchForcedCaseInsensitive(), $relationshipQuery->getConnection()),
);
$baseRelationshipQuery = $relationshipQuery->getQuery();
if (isset($baseRelationshipQuery->limit)) {
$component->optionsLimit($baseRelationshipQuery->limit);
} else {
$relationshipQuery->limit($component->getOptionsLimit());
}
$qualifiedRelatedKeyName = $component->getQualifiedRelatedKeyNameForRelationship($relationship);
if ($component->hasOptionLabelFromRecordUsingCallback()) {
return $relationshipQuery
->get()
->mapWithKeys(static fn (Model $record) => [
$record->{Str::afterLast($qualifiedRelatedKeyName, '.')} => $component->getOptionLabelFromRecord($record),
])
->toArray();
}
$relationshipTitleAttribute = $component->getRelationshipTitleAttribute();
if (empty($relationshipQuery->getQuery()->orders)) {
$relationshipOrderByAttribute = $relationshipTitleAttribute;
if (str_contains($relationshipOrderByAttribute, ' as ')) {
$relationshipOrderByAttribute = (string) str($relationshipOrderByAttribute)->before(' as ');
}
$relationshipQuery->orderBy($relationshipQuery->qualifyColumn($relationshipOrderByAttribute));
}
if (str_contains($relationshipTitleAttribute, '->')) {
if (! str_contains($relationshipTitleAttribute, ' as ')) {
$relationshipTitleAttribute .= " as {$relationshipTitleAttribute}";
}
} else {
$relationshipTitleAttribute = $relationshipQuery->qualifyColumn($relationshipTitleAttribute);
}
return $relationshipQuery
->pluck($relationshipTitleAttribute, $qualifiedRelatedKeyName)
->toArray();
});
$cachedRecords = null;
$cachedOptions = null;
$this->options(static function (Select $component) use ($modifyQueryUsing, $ignoreRecord, &$cachedRecords, &$cachedOptions): ?array {
if (($component->isSearchable()) && ! $component->isPreloaded()) {
return null;
}
$relationship = Relation::noConstraints(fn () => $component->getRelationship());
$qualifiedRelatedKeyName = $component->getQualifiedRelatedKeyNameForRelationship($relationship);
if ($component->hasOptionLabelFromRecordUsingCallback()) {
if (
(! $modifyQueryUsing) &&
(! $ignoreRecord) &&
($cachedRecords !== null)
) {
return $cachedRecords
->mapWithKeys(static fn (Model $record) => [
$record->{Str::afterLast($qualifiedRelatedKeyName, '.')} => $component->getOptionLabelFromRecord($record),
])
->toArray();
}
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
if ($ignoreRecord && ($record = $component->getRecord())) {
$relationshipQuery->where($record->getQualifiedKeyName(), '!=', $record->getKey());
}
if ($modifyQueryUsing) {
$relationshipQuery = $component->evaluate($modifyQueryUsing, [
'query' => $relationshipQuery,
'search' => null,
]) ?? $relationshipQuery;
}
$baseRelationshipQuery = $relationshipQuery->getQuery();
if (isset($baseRelationshipQuery->limit)) {
$component->optionsLimit($baseRelationshipQuery->limit);
} elseif ($component->isSearchable() && filled($component->getSearchColumns())) {
$relationshipQuery->limit($component->getOptionsLimit());
}
$records = $relationshipQuery->get();
if ((! $modifyQueryUsing) && (! $ignoreRecord)) {
$cachedRecords = $records;
}
return $records
->mapWithKeys(static fn (Model $record) => [
$record->{Str::afterLast($qualifiedRelatedKeyName, '.')} => $component->getOptionLabelFromRecord($record),
])
->toArray();
}
if (
(! $modifyQueryUsing) &&
(! $ignoreRecord) &&
($cachedOptions !== null)
) {
return $cachedOptions;
}
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
if ($ignoreRecord && ($record = $component->getRecord())) {
$relationshipQuery->where($record->getQualifiedKeyName(), '!=', $record->getKey());
}
if ($modifyQueryUsing) {
$relationshipQuery = $component->evaluate($modifyQueryUsing, [
'query' => $relationshipQuery,
'search' => null,
]) ?? $relationshipQuery;
}
$baseRelationshipQuery = $relationshipQuery->getQuery();
if (isset($baseRelationshipQuery->limit)) {
$component->optionsLimit($baseRelationshipQuery->limit);
} elseif ($component->isSearchable() && filled($component->getSearchColumns())) {
$relationshipQuery->limit($component->getOptionsLimit());
}
$relationshipTitleAttribute = $component->getRelationshipTitleAttribute();
if (empty($relationshipQuery->getQuery()->orders)) {
$relationshipOrderByAttribute = $relationshipTitleAttribute;
if (str_contains($relationshipOrderByAttribute, ' as ')) {
$relationshipOrderByAttribute = (string) str($relationshipOrderByAttribute)->before(' as ');
}
$relationshipQuery->orderBy($relationshipQuery->qualifyColumn($relationshipOrderByAttribute));
}
if (str_contains($relationshipTitleAttribute, '->')) {
if (! str_contains($relationshipTitleAttribute, ' as ')) {
$relationshipTitleAttribute .= " as {$relationshipTitleAttribute}";
}
} else {
$relationshipTitleAttribute = $relationshipQuery->qualifyColumn($relationshipTitleAttribute);
}
$options = $relationshipQuery
->pluck($relationshipTitleAttribute, $qualifiedRelatedKeyName)
->toArray();
if ((! $modifyQueryUsing) && (! $ignoreRecord)) {
$cachedOptions = $options;
}
return $options;
});
$this->loadStateFromRelationshipsUsing(static function (Select $component, $state) use ($modifyQueryUsing): void {
if (filled($state)) {
return;
}
$relationship = $component->getRelationship();
$relationshipName = $component->getRelationshipName();
if (
(! $modifyQueryUsing) &&
(! str_contains($relationshipName, '.')) &&
($record = $component->getRecord()) instanceof Model &&
$record->relationLoaded($relationshipName)
) {
$relatedRecords = $record->getRelationValue($relationshipName);
if (
($relationship instanceof BelongsToMany) ||
($relationship instanceof HasOneOrManyThrough)
) {
$relatedKeys = $relatedRecords
->pluck(($relationship instanceof BelongsToMany) ? $relationship->getRelatedKeyName() : $relationship->getRelated()->getKeyName())
->map(static fn ($key): string => strval($key));
$component->state(
$component->isMultiple()
? $relatedKeys->all()
: $relatedKeys->first(),
);
return;
}
if ($relationship instanceof BelongsToThrough) {
$component->state(
$relatedRecords?->getAttribute(
$relationship->getRelated()->getKeyName(),
),
);
return;
}
if ($relationship instanceof HasMany) {
$component->state(
$relatedRecords
->pluck($relationship->getLocalKeyName())
->all(),
);
return;
}
if ($relationship instanceof HasOne) {
$component->state(
$relatedRecords?->getAttribute(
$relationship->getLocalKeyName(),
),
);
return;
}
/** @var BelongsTo $relationship */
$component->state(
$relatedRecords?->getAttribute(
$relationship->getOwnerKeyName(),
),
);
return;
}
if (
($relationship instanceof BelongsToMany) ||
($relationship instanceof HasOneOrManyThrough)
) {
if ($modifyQueryUsing) {
$component->evaluate($modifyQueryUsing, [
'query' => $relationship->getQuery(),
'search' => null,
]);
}
/** @var Collection $relatedRecords */
$relatedRecords = $relationship->getResults();
// Cast the related keys to a string, otherwise
// JavaScript can't handle deselection.
//
// https://github.com/filamentphp/filament/issues/1111
$relatedKeys = $relatedRecords
->pluck(($relationship instanceof BelongsToMany) ? $relationship->getRelatedKeyName() : $relationship->getRelated()->getKeyName())
->map(static fn ($key): string => strval($key));
$component->state(
$component->isMultiple()
? $relatedKeys->all()
: $relatedKeys->first(),
);
return;
}
if ($relationship instanceof BelongsToThrough) {
/** @var ?Model $relatedModel */
$relatedModel = $relationship->getResults();
$component->state(
$relatedModel?->getAttribute(
$relationship->getRelated()->getKeyName(),
),
);
return;
}
if ($relationship instanceof HasMany) {
/** @var Collection $relatedRecords */
$relatedRecords = $relationship->getResults();
$component->state(
$relatedRecords
->pluck($relationship->getLocalKeyName())
->all(),
);
return;
}
if ($relationship instanceof HasOne) {
$relatedModel = $relationship->getResults();
$component->state(
$relatedModel?->getAttribute(
$relationship->getLocalKeyName(),
),
);
return;
}
/** @var BelongsTo $relationship */
$relatedModel = $relationship->getResults();
$component->state(
$relatedModel?->getAttribute(
$relationship->getOwnerKeyName(),
),
);
});
$this->getOptionLabelUsing(static function (Select $component) {
$record = $component->getSelectedRecord();
if (! $record) {
return null;
}
if ($component->hasOptionLabelFromRecordUsingCallback()) {
return $component->getOptionLabelFromRecord($record);
}
$relationshipTitleAttribute = $component->getRelationshipTitleAttribute();
if (str_contains($relationshipTitleAttribute, '->')) {
$relationshipTitleAttribute = str_replace('->', '.', $relationshipTitleAttribute);
}
return data_get($record, $relationshipTitleAttribute);
});
$this->getSelectedRecordUsing(static function (Select $component, $state) use ($modifyQueryUsing): ?Model {
$relationship = $component->getRelationship();
if (
(! $modifyQueryUsing) &&
($relationship instanceof BelongsTo)
) {
$record = $component->getRecord();
if (
($record instanceof Model) &&
$record->relationLoaded($component->getRelationshipName())
) {
$relatedRecord = $record->getRelationValue($component->getRelationshipName());
if (
($relatedRecord instanceof Model) &&
((string) $relatedRecord->getAttribute($relationship->getOwnerKeyName()) === (string) $state)
) {
return $relatedRecord;
}
}
}
$relationship = Relation::noConstraints(fn () => $component->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
$relationshipQuery->where($component->getQualifiedRelatedKeyNameForRelationship($relationship), $state);
if ($modifyQueryUsing) {
$relationshipQuery = $component->evaluate($modifyQueryUsing, [
'query' => $relationshipQuery,
'search' => null,
]) ?? $relationshipQuery;
}
return $relationshipQuery->first();
});
$this->getOptionLabelsUsing(static function (Select $component, array $values) use ($modifyQueryUsing): array {
$relationship = $component->getRelationship();
$record = $component->getRecord();
$relationshipName = $component->getRelationshipName();
if (
(! $modifyQueryUsing) &&
($record instanceof Model) &&
$record->relationLoaded($relationshipName) &&
(
($relationship instanceof BelongsToMany) ||
($relationship instanceof HasOneOrMany)
)
) {
$relatedRecords = $record->getRelationValue($relationshipName);
if ($relatedRecords instanceof Collection) {
$relatedKeyName = ($relationship instanceof BelongsToMany)
? $relationship->getRelatedKeyName()
: $relationship->getRelated()->getKeyName();
$loadedKeys = $relatedRecords->pluck($relatedKeyName)->map(fn ($key) => (string) $key)->all();
$requestedKeys = array_map(fn ($value) => (string) $value, $values);
if (empty(array_diff($requestedKeys, $loadedKeys))) {
$relationshipTitleAttribute = $component->getRelationshipTitleAttribute();
if (str_contains($relationshipTitleAttribute, '->')) {
$relationshipTitleAttribute = str_replace('->', '.', $relationshipTitleAttribute);
}
$filteredRecords = $relatedRecords->filter(
fn (Model $relatedRecord): bool => in_array(
(string) $relatedRecord->getAttribute($relatedKeyName),
$requestedKeys,
strict: true,
),
);
if ($component->hasOptionLabelFromRecordUsingCallback()) {
return $filteredRecords
->mapWithKeys(static fn (Model $relatedRecord) => [
$relatedRecord->getAttribute($relatedKeyName) => $component->getOptionLabelFromRecord($relatedRecord),
])
->toArray();
}
return $filteredRecords
->pluck($relationshipTitleAttribute, $relatedKeyName)
->toArray();
}
}
}
$relationship = Relation::noConstraints(fn () => $component->getRelationship());
$relationshipQuery = app(RelationshipJoiner::class)->prepareQueryForNoConstraints($relationship);
$qualifiedRelatedKeyName = $component->getQualifiedRelatedKeyNameForRelationship($relationship);
$relationshipQuery->whereIn($qualifiedRelatedKeyName, $values);
if ($modifyQueryUsing) {
$relationshipQuery = $component->evaluate($modifyQueryUsing, [
'query' => $relationshipQuery,
'search' => null,
]) ?? $relationshipQuery;
}
if ($component->hasOptionLabelFromRecordUsingCallback()) {
return $relationshipQuery
->get()
->mapWithKeys(static fn (Model $record) => [
$record->{Str::afterLast($qualifiedRelatedKeyName, '.')} => $component->getOptionLabelFromRecord($record),
])
->toArray();
}
$relationshipTitleAttribute = $component->getRelationshipTitleAttribute();
if (str_contains($relationshipTitleAttribute, '->')) {
if (! str_contains($relationshipTitleAttribute, ' as ')) {
$relationshipTitleAttribute .= " as {$relationshipTitleAttribute}";
}
} else {
$relationshipTitleAttribute = $relationshipQuery->qualifyColumn($relationshipTitleAttribute);
}
return $relationshipQuery
->pluck($relationshipTitleAttribute, $qualifiedRelatedKeyName)
->toArray();
});
$this->saveRelationshipsUsing(static function (Select $component, Model $record, $state) use ($modifyQueryUsing): void {
$relationship = $component->getRelationship();
if (($relationship instanceof HasOne) || ($relationship instanceof HasMany)) {
$query = $relationship->getQuery();
if ($modifyQueryUsing) {
$component->evaluate($modifyQueryUsing, [
'query' => $query,
'search' => null,
]);
}
$query->update([
$relationship->getForeignKeyName() => null,
]);
if (! empty($state)) {
$relationship::noConstraints(function () use ($component, $record, $state, $modifyQueryUsing): void {
$relationship = $component->getRelationship();
$query = $relationship->getQuery()->whereIn($relationship->getLocalKeyName(), Arr::wrap($state));
if ($modifyQueryUsing) {
$component->evaluate($modifyQueryUsing, [
'query' => $query,
'search' => null,
]);
}
$query->update([
$relationship->getForeignKeyName() => $record->getAttribute($relationship->getLocalKeyName()),
]);
});
}
return;
}
if (
($relationship instanceof HasOneOrMany) ||
($relationship instanceof HasOneOrManyThrough) ||
($relationship instanceof BelongsToThrough)
) {
return;
}
if (! $relationship instanceof BelongsToMany) {
// Security: If the model is new and the foreign key is already
// filled, don't overwrite it — the key may have been set by
// authorization logic or event listeners before save.
if (
$record->wasRecentlyCreated &&
filled($record->getAttributeValue($relationship->getForeignKeyName()))
) {
return;
}
$relationship->associate($state);
$record->wasRecentlyCreated && $record->save();
return;
}
if ($modifyQueryUsing) {
$component->evaluate($modifyQueryUsing, [
'query' => $relationship->getQuery(),
'search' => null,
]);
}
/** @var Collection $relatedRecords */
$relatedRecords = $relationship->getResults();
$state = Arr::wrap($state ?? []);
$recordsToDetach = array_diff(
$relatedRecords
->pluck($relationship->getRelatedKeyName())
->map(static fn ($key): string => strval($key))
->all(),
$state,
);
if (count($recordsToDetach) > 0) {
$relationship->detach($recordsToDetach);
}
$pivotData = $component->getPivotData();
if ($pivotData === []) {
$relationship->sync($state, detaching: false);
return;
}
$relationship->syncWithPivotValues($state, $pivotData, detaching: false);
});
$this->createOptionUsing(static function (Select $component, array $data, Schema $schema) {
$record = $component->getRelationship()->newModelInstance();
$record->fill($data);
$record->save();
$schema->model($record)->saveRelationships();
return $record->getKey();
});
$this->fillEditOptionActionFormUsing(static function (Select $component): ?array {
return $component->getSelectedRecord()?->attributesToArray();
});
$this->updateOptionUsing(static function (array $data, Schema $schema): void {
$schema->getRecord()?->update($data);
});
$this->dehydrated(fn (Select $component): bool => (! $component->isMultiple()) && $component->isSaved());
return $this;
}
/**
* @internal Do not use this method outside the internals of Filament. It is subject to breaking changes in minor and patch releases.
*/
public function applySearchConstraint(Builder $query, string $search): Builder
{
/** @var Connection $databaseConnection */
$databaseConnection = $query->getConnection();
$isForcedCaseInsensitive = $this->isSearchForcedCaseInsensitive();
$query->where(function (Builder $query) use ($databaseConnection, $isForcedCaseInsensitive, $search): Builder {
$isFirst = true;
foreach ($this->getSearchColumns() ?? [] as $searchColumn) {
$whereClause = $isFirst ? 'where' : 'orWhere';
$query->{$whereClause}(
generate_search_column_expression($searchColumn, $isForcedCaseInsensitive, $databaseConnection),
'like',
"%{$search}%",
);
$isFirst = false;
}
return $query;
});
return $query;
}
public function getOptionLabelFromRecordUsing(?Closure $callback): static
{
$this->getOptionLabelFromRecordUsing = $callback;
return $this;
}
public function hasOptionLabelFromRecordUsingCallback(): bool
{
return $this->getOptionLabelFromRecordUsing !== null;
}
public function getOptionLabelFromRecord(Model $record): string
{
return $this->evaluate(
$this->getOptionLabelFromRecordUsing,
namedInjections: [
'record' => $record,
],
typedInjections: [
Model::class => $record,
$record::class => $record,
],
);
}
public function getRelationshipTitleAttribute(): ?string
{
return $this->evaluate($this->relationshipTitleAttribute);
}
public function getLabel(): string | Htmlable | null
{
if ($this->label === null && $this->hasRelationship()) {
$label = (string) str($this->getRelationshipName())
->before('.')
->kebab()
->replace(['-', '_'], ' ')
->ucfirst();
return ($this->shouldTranslateLabel) ? __($label) : $label;
}
return parent::getLabel();
}
public function getRelationship(): BelongsTo | BelongsToMany | HasOneOrMany | HasOneOrManyThrough | BelongsToThrough | null
{
if (! $this->hasRelationship()) {
return null;
}
$record = $this->getModelInstance();
$relationship = null;
$relationshipName = $this->getRelationshipName();
foreach (explode('.', $relationshipName) as $nestedRelationshipName) {
if ($record->hasAttribute($nestedRelationshipName)) {
$relationship = null;
break;
}
if (! $record->isRelation($nestedRelationshipName)) {
$relationship = null;
break;
}
$relationship = $record->{$nestedRelationshipName}();
$record = $relationship->getRelated();
}
if (! $relationship) {
throw new LogicException("The relationship [{$relationshipName}] does not exist on the model [{$this->getModel()}].");
}
return $relationship;
}
public function getRelationshipName(): ?string
{
return $this->evaluate($this->relationship);
}
public function getSelectedRecord(): ?Model
{
if ($this->cachedSelectedRecord) {
return $this->cachedSelectedRecord;
}
if (blank($this->getState())) {
return null;
}
return $this->cachedSelectedRecord = $this->evaluate($this->getSelectedRecordUsing);
}
public function hasRelationship(): bool
{
return filled($this->getRelationshipName());
}
public function dynamicOptions(bool | Closure | null $condition = true): static
{
$this->hasDynamicOptions = $condition;
return $this;
}
public function hasDynamicOptions(): bool
{
if (($condition = $this->evaluate($this->hasDynamicOptions)) !== null) {
return $condition;
}
if ($this->hasDynamicDisabledOptions()) {
return true;
}
if ($this->hasRelationship()) {
return (! $this->isSearchable()) || $this->isPreloaded();
}
if ($this->isPreloaded()) {
return true;
}
return $this->options instanceof Closure;
}
public function hasDynamicSearchResults(): bool
{
if ($this->hasRelationship() && blank($this->getSearchColumns())) {
return false;
}
return $this->getSearchResultsUsing instanceof Closure;
}
public function hasInitialNoOptionsMessage(): bool
{
if ($this->hasRelationship()) {
return (! $this->isSearchable()) || $this->isPreloaded();
}
if ($this->isPreloaded()) {
return true;
}
return ! $this->hasDynamicSearchResults();
}
/**
* @return Model | array<string, mixed> | class-string<Model> | null
*/
public function getActionSchemaModel(): Model | array | string | null
{
if ($this->hasRelationship()) {
return $this->getRelationship()->getModel()::class;
}
return parent::getActionSchemaModel();
}
public function getOptionsLimit(): int
{
return $this->evaluate($this->optionsLimit);
}
public function getMaxItemsMessage(): string
{
$maxItems = $this->getMaxItems();
return $this->evaluate($this->maxItemsMessage) ?? trans_choice('filament-forms::components.select.max_items_message', $maxItems, [
':count' => $maxItems,
]);
}
public function forceSearchCaseInsensitive(bool | Closure | null $condition = true): static
{
$this->isSearchForcedCaseInsensitive = $condition;
return $this;
}
public function isSearchForcedCaseInsensitive(): ?bool
{
return $this->evaluate($this->isSearchForcedCaseInsensitive);
}
public function wrapOptionLabels(bool | Closure $condition = true): static
{
$this->canOptionLabelsWrap = $condition;
return $this;
}
public function canOptionLabelsWrap(): bool
{
return (bool) $this->evaluate($this->canOptionLabelsWrap);
}
public function getQualifiedRelatedKeyNameForRelationship(Relation $relationship): string
{
if ($relationship instanceof BelongsToMany) {
return $relationship->getQualifiedRelatedKeyName();
}
if ($relationship instanceof HasOneOrManyThrough) {
return $relationship->getQualifiedForeignKeyName();
}
if (
($relationship instanceof HasOneOrMany) ||
($relationship instanceof BelongsToThrough)
) {
return $relationship->getRelated()->getQualifiedKeyName();
}
/** @var BelongsTo $relationship */
return $relationship->getQualifiedOwnerKeyName();
}
public function refreshSelectedOptionLabel(): void
{
$livewire = $this->getLivewire();
$livewire->dispatch(
'filament-forms::select.refreshSelectedOptionLabel',
livewireId: $livewire->getId(),
statePath: $this->getStatePath(),
);
}
public function getEnumDefaultStateCast(): ?StateCast
{
$enum = $this->getEnum();
if (blank($enum)) {
return null;
}
return app(
$this->isMultiple() ? EnumArrayStateCast::class : EnumStateCast::class,
['enum' => $enum],
);
}
/**
* @return array<StateCast>
*/
public function getDefaultStateCasts(): array
{
if ($this->hasCustomStateCasts() || filled($this->getEnum())) {
return parent::getDefaultStateCasts();
}
if ($this->isMultiple()) {
return [app(OptionsArrayStateCast::class)];
}
return [app(OptionStateCast::class, ['isNullable' => true])];
}
/**
* @return ?array<string>
*/
public function getInValidationRuleValues(): ?array
{
$values = parent::getInValidationRuleValues();
if ($values !== null) {
return $values;
}
if ($this->isMultiple()) {
if ((! $this->getOptionLabelsUsing) && ($this->options === null)) {
throw new LogicException("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();
if (blank($state)) {
return null;
}
$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;
}
if ((! $this->getOptionLabelUsing) && ($this->options === null)) {
throw new LogicException("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.");
}
$state = $this->getState();
if (blank($state)) {
return null;
}
$optionLabel = $this->getOptionLabel(withDefault: false);
if (blank($optionLabel)) {
return [];
}
if ($state instanceof BackedEnum) {
$state = $state->value;
}
if ($this->hasDisabledOptions() && $this->isOptionDisabled($state, $optionLabel)) {
return [];
}
return null;
}
public function hasInValidationOnMultipleValues(): bool
{
return $this->isMultiple();
}
public function hasNullableBooleanState(): bool
{
return true;
}
}