From 7c6151a46d8b5a4371db790bbacef066cd0b2b5c Mon Sep 17 00:00:00 2001 From: WEN RENHAI Date: Tue, 27 Jan 2026 19:53:10 +0800 Subject: [PATCH] [4.x] Fix allow tabs to exclude query when resolving records (#19101) * fix: Allow tabs to exclude query when resolving records * docs: Add `excludeQueryWhenResolvingRecord()` for tabs * Update 02-listing-records.md * Update 02-listing-records.md --------- Co-authored-by: Dan Harrin --- docs/03-resources/02-listing-records.md | 30 +++++++++++ .../panels/src/Resources/Concerns/HasTabs.php | 10 +++- packages/schemas/src/Components/Tabs/Tab.php | 14 ++++++ packages/tables/src/Concerns/HasRecords.php | 6 ++- .../tables/src/Table/Concerns/HasQuery.php | 13 +++-- .../Posts/Pages/ListPostsWithTabs.php | 41 +++++++++++++++ .../DepartmentsRelationManagerWithTabs.php | 50 +++++++++++++++++++ .../Resources/Pages/ListRecordsTest.php | 30 +++++++++++ .../Panels/Resources/RelationManagerTest.php | 43 ++++++++++++++++ 9 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 tests/src/Fixtures/Resources/Posts/Pages/ListPostsWithTabs.php create mode 100644 tests/src/Fixtures/Resources/Tickets/RelationManagers/DepartmentsRelationManagerWithTabs.php diff --git a/docs/03-resources/02-listing-records.md b/docs/03-resources/02-listing-records.md index 59a4eb558a..d44e4ae94a 100644 --- a/docs/03-resources/02-listing-records.md +++ b/docs/03-resources/02-listing-records.md @@ -1,6 +1,7 @@ --- title: Listing records --- +import Aside from "@components/Aside.astro" ## Using tabs to filter the records @@ -119,6 +120,35 @@ public function getDefaultActiveTab(): string | int | null } ``` +### Excluding the tab query when resolving records + +When a user interacts with a table record (e.g., clicking an action button), Filament resolves that record from the database. By default, the active tab's query is applied, ensuring users cannot access records outside the current tab's scope. + +However, when a record's state changes after the user saw it in the table, you may still want the user to interact with it. For example, if you have an "Active" tab and an action sets a record to inactive, subsequent actions in the same modal would fail to resolve that record. + +You may mark a tab to be excluded when resolving records using the `excludeQueryWhenResolvingRecord()` method: + +```php +use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Database\Eloquent\Builder; + +public function getTabs(): array +{ + return [ + 'active' => Tab::make() + ->modifyQueryUsing(fn (Builder $query) => $query->where('active', true)) + ->excludeQueryWhenResolvingRecord(), + 'inactive' => Tab::make() + ->modifyQueryUsing(fn (Builder $query) => $query->where('active', false)) + ->excludeQueryWhenResolvingRecord(), + ]; +} +``` + + + ## Authorization For authorization, Filament will observe any [model policies](https://laravel.com/docs/authorization#creating-policies) that are registered in your app. diff --git a/packages/panels/src/Resources/Concerns/HasTabs.php b/packages/panels/src/Resources/Concerns/HasTabs.php index 66eb993b9d..5b926e2b0c 100644 --- a/packages/panels/src/Resources/Concerns/HasTabs.php +++ b/packages/panels/src/Resources/Concerns/HasTabs.php @@ -64,7 +64,7 @@ trait HasTabs ->ucfirst(); } - protected function modifyQueryWithActiveTab(Builder $query): Builder + protected function modifyQueryWithActiveTab(Builder $query, bool $isResolvingRecord = false): Builder { if (blank(filled($this->activeTab))) { return $query; @@ -76,7 +76,13 @@ trait HasTabs return $query; } - return $tabs[$this->activeTab]->modifyQuery($query); + $tab = $tabs[$this->activeTab]; + + if ($isResolvingRecord && $tab->shouldExcludeQueryWhenResolvingRecord()) { + return $query; + } + + return $tab->modifyQuery($query); } public function getTabsContentComponent(): Component diff --git a/packages/schemas/src/Components/Tabs/Tab.php b/packages/schemas/src/Components/Tabs/Tab.php index 6eef61fed3..aabe555368 100644 --- a/packages/schemas/src/Components/Tabs/Tab.php +++ b/packages/schemas/src/Components/Tabs/Tab.php @@ -27,6 +27,8 @@ class Tab extends Component implements CanConcealComponents protected ?Closure $modifyQueryUsing = null; + protected bool | Closure $shouldExcludeQueryWhenResolvingRecord = false; + protected string | BackedEnum | Htmlable | Closure | null $badgeIcon = null; protected IconPosition | string | Closure | null $badgeIconPosition = null; @@ -124,4 +126,16 @@ class Tab extends Component implements CanConcealComponents { return $this->evaluate($this->badgeIconPosition) ?? IconPosition::Before; } + + public function excludeQueryWhenResolvingRecord(bool | Closure $condition = true): static + { + $this->shouldExcludeQueryWhenResolvingRecord = $condition; + + return $this; + } + + public function shouldExcludeQueryWhenResolvingRecord(): bool + { + return (bool) $this->evaluate($this->shouldExcludeQueryWhenResolvingRecord); + } } diff --git a/packages/tables/src/Concerns/HasRecords.php b/packages/tables/src/Concerns/HasRecords.php index aaf80cbc72..d6fe9150b5 100644 --- a/packages/tables/src/Concerns/HasRecords.php +++ b/packages/tables/src/Concerns/HasRecords.php @@ -193,7 +193,7 @@ trait HasRecords if (! ($this->getTable()->getRelationship() instanceof BelongsToMany)) { return $this->applyFiltersToTableQuery( - $this->getTable()->getQuery(), + $this->getTable()->getQuery(isResolvingRecord: true), isResolvingRecord: true, )->find($key); } @@ -206,7 +206,9 @@ trait HasRecords $table = $this->getTable(); - $this->applyFiltersToTableQuery($relationship->getQuery(), isResolvingRecord: true); + $relationshipQuery = $relationship->getQuery(); + $table->applyQueryScopes($relationshipQuery, isResolvingRecord: true); + $this->applyFiltersToTableQuery($relationshipQuery, isResolvingRecord: true); $query = $table->allowsDuplicates() ? $relationship->wherePivot($pivotKeyName, $key) : diff --git a/packages/tables/src/Table/Concerns/HasQuery.php b/packages/tables/src/Table/Concerns/HasQuery.php index e39f42c8e6..bece413b34 100644 --- a/packages/tables/src/Table/Concerns/HasQuery.php +++ b/packages/tables/src/Table/Concerns/HasQuery.php @@ -53,23 +53,26 @@ trait HasQuery return $this; } - protected function applyQueryScopes(Builder $query): Builder + public function applyQueryScopes(Builder $query, bool $isResolvingRecord = false): Builder { foreach ($this->queryScopes as $scope) { - $query = $this->evaluate($scope, ['query' => $query]) ?? $query; + $query = $this->evaluate($scope, [ + 'query' => $query, + 'isResolvingRecord' => $isResolvingRecord, + ]) ?? $query; } return $query; } - public function getQuery(): Builder | Relation | null + public function getQuery(bool $isResolvingRecord = false): Builder | Relation | null { if ($query = $this->evaluate($this->query)) { - return $this->applyQueryScopes($query->clone()); + return $this->applyQueryScopes($query->clone(), $isResolvingRecord); } if ($query = $this->getRelationshipQuery()) { - return $this->applyQueryScopes($query->clone()); + return $this->applyQueryScopes($query->clone(), $isResolvingRecord); } return null; diff --git a/tests/src/Fixtures/Resources/Posts/Pages/ListPostsWithTabs.php b/tests/src/Fixtures/Resources/Posts/Pages/ListPostsWithTabs.php new file mode 100644 index 0000000000..7a7b222134 --- /dev/null +++ b/tests/src/Fixtures/Resources/Posts/Pages/ListPostsWithTabs.php @@ -0,0 +1,41 @@ +modifyQueryUsing(fn ($query) => $query->where('is_published', true)); + + $draft = Tab::make('Draft') + ->modifyQueryUsing(fn ($query) => $query->where('is_published', false)); + + if ($this->shouldExcludeTabQueryWhenResolvingRecord) { + $published->excludeQueryWhenResolvingRecord(); + $draft->excludeQueryWhenResolvingRecord(); + } + + return [ + 'published' => $published, + 'draft' => $draft, + ]; + } +} diff --git a/tests/src/Fixtures/Resources/Tickets/RelationManagers/DepartmentsRelationManagerWithTabs.php b/tests/src/Fixtures/Resources/Tickets/RelationManagers/DepartmentsRelationManagerWithTabs.php new file mode 100644 index 0000000000..220c5cee6a --- /dev/null +++ b/tests/src/Fixtures/Resources/Tickets/RelationManagers/DepartmentsRelationManagerWithTabs.php @@ -0,0 +1,50 @@ +headerActions([ + CreateAction::make(), + ]); + } + + public function form(Schema $schema): Schema + { + return DepartmentForm::configure($schema); + } + + public function getTabs(): array + { + $aNames = Tab::make('Names starting with A') + ->modifyQueryUsing(fn ($query) => $query->where('name', 'LIKE', 'A%')); + + $other = Tab::make('Other names') + ->modifyQueryUsing(fn ($query) => $query->where('name', 'NOT LIKE', 'A%')); + + if ($this->shouldExcludeTabQueryWhenResolvingRecord) { + $aNames->excludeQueryWhenResolvingRecord(); + $other->excludeQueryWhenResolvingRecord(); + } + + return [ + 'a_names' => $aNames, + 'other' => $other, + ]; + } +} diff --git a/tests/src/Panels/Resources/Pages/ListRecordsTest.php b/tests/src/Panels/Resources/Pages/ListRecordsTest.php index ced4d29e3c..f7539c1dd7 100644 --- a/tests/src/Panels/Resources/Pages/ListRecordsTest.php +++ b/tests/src/Panels/Resources/Pages/ListRecordsTest.php @@ -18,6 +18,7 @@ use Filament\Tests\Fixtures\Models\TicketMessage; use Filament\Tests\Fixtures\Models\User; use Filament\Tests\Fixtures\Policies\TicketPolicy; use Filament\Tests\Fixtures\Resources\Posts\Pages\ListPosts; +use Filament\Tests\Fixtures\Resources\Posts\Pages\ListPostsWithTabs; use Filament\Tests\Fixtures\Resources\Posts\PostResource; use Filament\Tests\Fixtures\Resources\TicketMessages\TicketMessageResource; use Filament\Tests\Fixtures\Resources\Tickets\Pages\ListTickets; @@ -324,3 +325,32 @@ it('renders actions based on policy', function (string $action, string $policyMe 'restore bulk action with policy returning false' => fn (): array => [RestoreBulkAction::class, 'restoreAny', false, false, true, true, true], 'restore bulk action with policy returning denied response' => fn (): array => [RestoreBulkAction::class, 'restoreAny', Response::deny(), false, true, true, true], ]); + +it('can access record for action after record no longer matches tab query', function (): void { + $post = Post::factory()->create(['is_published' => true]); + + livewire(ListPostsWithTabs::class) + ->set('activeTab', 'published') + ->assertCanSeeTableRecords([$post]) + ->tap(fn () => $post->update(['is_published' => false])) + ->callAction(TestAction::make(DeleteAction::class)->table($post)); + + expect($post->fresh()->trashed())->toBeTrue(); +}); + +it('cannot access record for action after record no longer matches tab without `excludeQueryWhenResolvingRecord()`', function (): void { + $post = Post::factory()->create(['is_published' => true]); + + livewire(ListPostsWithTabs::class) + ->set('shouldExcludeTabQueryWhenResolvingRecord', false) + ->set('activeTab', 'published') + ->assertCanSeeTableRecords([$post]) + ->tap(fn () => $post->update(['is_published' => false])); + + expect( + fn () => livewire(ListPostsWithTabs::class) + ->set('shouldExcludeTabQueryWhenResolvingRecord', false) + ->set('activeTab', 'published') + ->mountTableAction(DeleteAction::class, $post) + )->toThrow(TypeError::class); +}); diff --git a/tests/src/Panels/Resources/RelationManagerTest.php b/tests/src/Panels/Resources/RelationManagerTest.php index f335d2fb87..d726c0c195 100644 --- a/tests/src/Panels/Resources/RelationManagerTest.php +++ b/tests/src/Panels/Resources/RelationManagerTest.php @@ -18,6 +18,7 @@ use Filament\Tests\Fixtures\Models\Ticket; use Filament\Tests\Fixtures\Policies\DepartmentPolicy; use Filament\Tests\Fixtures\Resources\Tickets\Pages\EditTicket; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsRelationManager; +use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsRelationManagerWithTabs; use Filament\Tests\Fixtures\Resources\Tickets\RelationManagers\DepartmentsWithAttachTableSelectRelationManager; use Filament\Tests\Panels\Resources\TestCase; use Illuminate\Auth\Access\Response; @@ -271,3 +272,45 @@ it('can access record for action after record no longer matches `TrashedFilter` expect($department->fresh()->trashed())->toBeTrue(); }); + +it('can access record for action after record no longer matches tab query in `BelongsToMany` relation manager', function (): void { + $ticket = Ticket::factory()->create(); + $department = Department::factory()->create(['name' => 'Accounting']); + $ticket->departments()->attach($department); + + livewire(DepartmentsRelationManagerWithTabs::class, [ + 'ownerRecord' => $ticket, + 'pageClass' => EditTicket::class, + ]) + ->set('activeTab', 'a_names') + ->assertCanSeeTableRecords([$department]) + ->tap(fn () => $department->update(['name' => 'Billing'])) + ->callAction(TestAction::make(DeleteAction::class)->table($department)); + + expect($department->fresh()->trashed())->toBeTrue(); +}); + +it('cannot access record for action after record no longer matches tab without `excludeQueryWhenResolvingRecord()` in `BelongsToMany` relation manager', function (): void { + $ticket = Ticket::factory()->create(); + $department = Department::factory()->create(['name' => 'Accounting']); + $ticket->departments()->attach($department); + + livewire(DepartmentsRelationManagerWithTabs::class, [ + 'ownerRecord' => $ticket, + 'pageClass' => EditTicket::class, + ]) + ->set('shouldExcludeTabQueryWhenResolvingRecord', false) + ->set('activeTab', 'a_names') + ->assertCanSeeTableRecords([$department]) + ->tap(fn () => $department->update(['name' => 'Billing'])); + + expect( + fn () => livewire(DepartmentsRelationManagerWithTabs::class, [ + 'ownerRecord' => $ticket, + 'pageClass' => EditTicket::class, + ]) + ->set('shouldExcludeTabQueryWhenResolvingRecord', false) + ->set('activeTab', 'a_names') + ->mountTableAction(DeleteAction::class, $department) + )->toThrow(TypeError::class); +});