Repository: filamentphp/filament (php)

Issue:
Make MultiSelectFilter extend SelectFilter

Pull request description:
chore: Deprecate MultiSelectFilter
Closes #4266.

Patch:
diff --git a/packages/tables/docs/04-filters.md b/packages/tables/docs/04-filters.md
index f80eee2594..565ccd2e3d 100644
--- a/packages/tables/docs/04-filters.md
+++ b/packages/tables/docs/04-filters.md
@@ -111,34 +111,15 @@ SelectFilter::make('status')
     ->attribute('status_id')
 ```
 
-#### Relationship select filters
-
-Select filters are also able to automatically populate themselves based on a `BelongsTo` relationship. For example, if your table has a `author` relationship with a `name` column, you may use `relationship()` to filter the records belonging to an author:
-
-```php
-use Filament\Tables\Filters\SelectFilter;
+#### Multi-select filters
 
-SelectFilter::make('author')->relationship('author', 'name')
-```
-
-You may customise the database query that retrieves options using the third parameter of the `relationship()` method:
+These allow the user to select multiple options to apply the filter to their table. For example, a status filter may present the user with a few status options to pick from and filter the table using:
 
 ```php
 use Filament\Tables\Filters\SelectFilter;
-use Illuminate\Database\Eloquent\Builder;
-
-SelectFilter::make('author')
-    ->relationship('author', 'name', fn (Builder $query) => $query->withTrashed())
-```
-
-### Multi-select filters
-
-Multi-select filters allow you to quickly create a filter that allows the user to select multiple options to apply the filter to their table. For example, a status filter may present the user with a few status options to pick from and filter the table using:
-
-```php
-use Filament\Tables\Filters\MultiSelectFilter;
 
-MultiSelectFilter::make('status')
+SelectFilter::make('status')
+    ->multiple()
     ->options([
         'draft' => 'Draft',
         'reviewing' => 'Reviewing',
@@ -146,28 +127,24 @@ MultiSelectFilter::make('status')
     ])
 ```
 
-Multi-select filters do not require a custom `query()` method. The column name used to scope the query is the name of the filter. To customize this, you may use the `attribute()` method:
+#### Relationship select filters
+
+Select filters are also able to automatically populate themselves based on a `BelongsTo` relationship. For example, if your table has a `author` relationship with a `name` column, you may use `relationship()` to filter the records belonging to an author:
 
 ```php
-use Filament\Tables\Filters\MultiSelectFilter;
+use Filament\Tables\Filters\SelectFilter;
 
-MultiSelectFilter::make('status')
-    ->options([
-        'draft' => 'Draft',
-        'reviewing' => 'Reviewing',
-        'published' => 'Published',
-    ])
-    ->attribute('status_id')
+SelectFilter::make('author')->relationship('author', 'name')
 ```
 
-#### Relationship multi-select filters
-
-Multi-select filters are also able to automatically populate themselves based on a `BelongsTo` relationship. For example, if your table has a `author` relationship with a `name` column, you may use `relationship()` to filter the records belonging to a selection of authors:
+You may customise the database query that retrieves options using the third parameter of the `relationship()` method:
 
 ```php
-use Filament\Tables\Filters\MultiSelectFilter;
+use Filament\Tables\Filters\SelectFilter;
+use Illuminate\Database\Eloquent\Builder;
 
-MultiSelectFilter::make('author')->relationship('author', 'name')
+SelectFilter::make('author')
+    ->relationship('author', 'name', fn (Builder $query) => $query->withTrashed())
 ```
 
 ### Ternary filters
diff --git a/packages/tables/src/Filters/MultiSelectFilter.php b/packages/tables/src/Filters/MultiSelectFilter.php
index 6626754d55..f79f2c76a8 100644
--- a/packages/tables/src/Filters/MultiSelectFilter.php
+++ b/packages/tables/src/Filters/MultiSelectFilter.php
@@ -7,119 +7,13 @@ use Filament\Forms\Components\Select;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Arr;
 
-class MultiSelectFilter extends BaseFilter
+/**
+ * @deprecated Use SelectFilter with the `multiple()` method instead.
+ */
+class MultiSelectFilter extends SelectFilter
 {
-    use Concerns\HasOptions;
-    use Concerns\HasPlaceholder;
-    use Concerns\HasRelationship;
-
-    protected string | Closure | null $attribute = null;
-
-    protected bool | Closure $isStatic = false;
-
-    protected function setUp(): void
-    {
-        parent::setUp();
-
-        $this->placeholder(__('tables::table.filters.multi_select.placeholder'));
-
-        $this->indicateUsing(function (array $state): array {
-            if (blank($state['values'] ?? null)) {
-                return [];
-            }
-
-            $labels = Arr::only($this->getOptions(), $state['values']);
-
-            if (! count($labels)) {
-                return [];
-            }
-
-            $labels = collect($labels)->join(', ', ' & ');
-
-            return ["{$this->getIndicator()}: {$labels}"];
-        });
-    }
-
-    public function apply(Builder $query, array $data = []): Builder
-    {
-        if ($this->evaluate($this->isStatic)) {
-            return $query;
-        }
-
-        if ($this->hasQueryModificationCallback()) {
-            return parent::apply($query, $data);
-        }
-
-        if (blank($data['values'] ?? null)) {
-            return $query;
-        }
-
-        if ($this->queriesRelationships()) {
-            return $query->whereHas(
-                $this->getRelationshipName(),
-                fn (Builder $query) => $query->whereIn(
-                    $this->getRelationshipKey(),
-                    $data['values'],
-                ),
-            );
-        }
-
-        /** @var Builder $query */
-        $query = $query->whereIn($this->getAttribute(), $data['values']);
-
-        return $query;
-    }
-
-    public function attribute(string | Closure | null $name): static
+    public function isMultiple(): bool
     {
-        $this->attribute = $name;
-
-        return $this;
-    }
-
-    /**
-     * @deprecated Use `attribute()` instead.
-     */
-    public function column(string | Closure | null $name): static
-    {
-        $this->attribute($name);
-
-        return $this;
-    }
-
-    public function static(bool | Closure $condition = true): static
-    {
-        $this->isStatic = $condition;
-
-        return $this;
-    }
-
-    public function getAttribute(): string
-    {
-        return $this->evaluate($this->attribute) ?? $this->getName();
-    }
-
-    /**
-     * @deprecated Use `getAttribute()` instead.
-     */
-    public function getColumn(): string
-    {
-        return $this->getAttribute();
-    }
-
-    protected function getFormField(): Select
-    {
-        $field = Select::make('values')
-            ->multiple()
-            ->label($this->getLabel())
-            ->options($this->getOptions())
-            ->placeholder($this->getPlaceholder())
-            ->default($this->getDefaultState());
-
-        if (filled($defaultState = $this->getDefaultState())) {
-            $field->default($defaultState);
-        }
-
-        return $field;
+        return true;
     }
 }
diff --git a/packages/tables/src/Filters/SelectFilter.php b/packages/tables/src/Filters/SelectFilter.php
index 36fe5d200a..41e5c83a89 100644
--- a/packages/tables/src/Filters/SelectFilter.php
+++ b/packages/tables/src/Filters/SelectFilter.php
@@ -5,6 +5,9 @@ namespace Filament\Tables\Filters;
 use Closure;
 use Filament\Forms\Components\Select;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Arr;
+use Spatie\LaravelIgnition\Exceptions\ViewException;
+use Throwable;
 
 class SelectFilter extends BaseFilter
 {
@@ -14,6 +17,8 @@ class SelectFilter extends BaseFilter
 
     protected string | Closure | null $attribute = null;
 
+    protected bool | Closure $isMultiple = false;
+
     protected bool | Closure $isStatic = false;
 
     protected bool | Closure $isSearchable = false;
@@ -24,9 +29,29 @@ class SelectFilter extends BaseFilter
     {
         parent::setUp();
 
-        $this->placeholder(__('tables::table.filters.select.placeholder'));
+        $this->placeholder(
+            fn (SelectFilter $filter): string => $filter->isMultiple() ?
+                __('tables::table.filters.multi_select.placeholder') :
+                __('tables::table.filters.select.placeholder'),
+        );
+
+        $this->indicateUsing(function (SelectFilter $filter, array $state): array {
+            if ($filter->isMultiple()) {
+                if (blank($state['values'] ?? null)) {
+                    return [];
+                }
+
+                $labels = Arr::only($this->getOptions(), $state['values']);
+
+                if (! count($labels)) {
+                    return [];
+                }
+
+                $labels = collect($labels)->join(', ', ' & ');
+
+                return ["{$this->getIndicator()}: {$labels}"];
+            }
 
-        $this->indicateUsing(function (array $state): array {
             if (blank($state['value'] ?? null)) {
                 return [];
             }
@@ -51,19 +76,41 @@ class SelectFilter extends BaseFilter
             return parent::apply($query, $data);
         }
 
-        if (blank($data['value'] ?? null)) {
+        $isMultiple = $this->isMultiple();
+
+        $values = $isMultiple ?
+            $data['values'] ?? null :
+            $data['value'] ?? null;
+
+        if (! count(array_filter(
+            Arr::wrap($values),
+            fn ($value) => filled($value),
+        ))) {
             return $query;
         }
 
-        if ($this->queriesRelationships()) {
-            return $query->whereRelation(
+        if (! $this->queriesRelationships()) {
+            return $query->{$isMultiple ? 'whereIn' : 'where'}(
+                $this->getAttribute(),
+                $values,
+            );
+        }
+
+        if ($isMultiple) {
+            return $query->whereHas(
                 $this->getRelationshipName(),
-                $this->getRelationshipKey(),
-                $data['value'],
+                fn (Builder $query) => $query->whereIn(
+                    $this->getRelationshipKey(),
+                    $values,
+                ),
             );
         }
 
-        return $query->where($this->getAttribute(), $data['value']);
+        return $query->whereRelation(
+            $this->getRelationshipName(),
+            $this->getRelationshipKey(),
+            $values,
+        );
     }
 
     public function attribute(string | Closure | null $name): static
@@ -90,6 +137,13 @@ class SelectFilter extends BaseFilter
         return $this;
     }
 
+    public function multiple(bool | Closure $condition = true): static
+    {
+        $this->isMultiple = $condition;
+
+        return $this;
+    }
+
     public function searchable(bool | Closure $condition = true): static
     {
         $this->isSearchable = $condition;
@@ -120,7 +174,8 @@ class SelectFilter extends BaseFilter
      */
     protected function getFormSelectComponent(): Select
     {
-        $field = Select::make('value')
+        $field = Select::make($this->isMultiple() ? 'values' : 'value')
+            ->multiple($this->isMultiple())
             ->label($this->getLabel())
             ->options($this->getOptions())
             ->placeholder($this->getPlaceholder())
@@ -134,6 +189,11 @@ class SelectFilter extends BaseFilter
         return $field;
     }
 
+    public function isMultiple(): bool
+    {
+        return (bool) $this->evaluate($this->isMultiple);
+    }
+
     public function isSearchable(): bool
     {
         return (bool) $this->evaluate($this->isSearchable);


Test patch:
diff --git a/packages/tables/src/Testing/TestsFilters.php b/packages/tables/src/Testing/TestsFilters.php
index 234cfe3d40..2804fd55b9 100644
--- a/packages/tables/src/Testing/TestsFilters.php
+++ b/packages/tables/src/Testing/TestsFilters.php
@@ -5,7 +5,6 @@ namespace Filament\Tables\Testing;
 use Closure;
 use Filament\Tables\Contracts\HasTable;
 use Filament\Tables\Filters\BaseFilter;
-use Filament\Tables\Filters\MultiSelectFilter;
 use Filament\Tables\Filters\SelectFilter;
 use Filament\Tables\Filters\TernaryFilter;
 use Illuminate\Database\Eloquent\Model;
@@ -38,13 +37,15 @@ class TestsFilters
                 } else {
                     $data = ['value' => $data];
                 }
-            } elseif ($filter instanceof MultiSelectFilter) {
-                $data = ['values' => array_map(
-                    fn ($record) => $record instanceof Model ? $record->getKey() : $record,
-                    Arr::wrap($data ?? []),
-                )];
             } elseif ($filter instanceof SelectFilter) {
-                $data = ['value' => $data instanceof Model ? $data->getKey() : $data];
+                if ($filter->isMultiple()) {
+                    $data = ['values' => array_map(
+                        fn ($record) => $record instanceof Model ? $record->getKey() : $record,
+                        Arr::wrap($data ?? []),
+                    )];
+                } else {
+                    $data = ['value' => $data instanceof Model ? $data->getKey() : $data];
+                }
             } elseif (! is_array($data)) {
                 $data = ['isActive' => $data === true || $data === null];
             }