mirror of
https://github.com/filamentphp/filament.git
synced 2026-09-01 15:09:33 +08:00
[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user