[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 <git@danharrin.com>
This commit is contained in:
WEN RENHAI
2026-01-27 19:53:10 +08:00
committed by GitHub
parent da8d94f9b7
commit 7c6151a46d
9 changed files with 228 additions and 9 deletions
+30
View File
@@ -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(),
];
}
```
<Aside variant="danger">
Do not use `excludeQueryWhenResolvingRecord()` on tabs that enforce authorization rules. For example, if you have a tab that restricts records by tenant or user ownership, those tabs should remain enforced to prevent unauthorized access.
</Aside>
## Authorization
For authorization, Filament will observe any [model policies](https://laravel.com/docs/authorization#creating-policies) that are registered in your app.
@@ -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
@@ -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);
}
}
+4 -2
View File
@@ -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) :
@@ -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;
@@ -0,0 +1,41 @@
<?php
namespace Filament\Tests\Fixtures\Resources\Posts\Pages;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Tests\Fixtures\Resources\Posts\PostResource;
class ListPostsWithTabs extends ListRecords
{
protected static string $resource = PostResource::class;
public bool $shouldExcludeTabQueryWhenResolvingRecord = true;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
public function getTabs(): array
{
$published = Tab::make('Published')
->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,
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace Filament\Tests\Fixtures\Resources\Tickets\RelationManagers;
use Filament\Actions\CreateAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Filament\Tests\Fixtures\Resources\Departments\Schemas\DepartmentForm;
use Filament\Tests\Fixtures\Resources\Departments\Tables\DepartmentsTable;
class DepartmentsRelationManagerWithTabs extends RelationManager
{
protected static string $relationship = 'departments';
public bool $shouldExcludeTabQueryWhenResolvingRecord = true;
public function table(Table $table): Table
{
return DepartmentsTable::configure($table)
->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,
];
}
}
@@ -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);
});
@@ -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);
});