From bffa8a7de4b74a0fd673635559363bbfedf325e2 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 25 Aug 2026 10:36:16 +0100 Subject: [PATCH 1/2] Add status tabs and a column manager to the admin Plugins list Adds Pending / Approved / Rejected / All tabs to the admin Plugins list, matching the pattern already used for Support Tickets. Pending is the default tab, and the Approved tab sorts by approval date, newest first. The Approved tab's ordering is applied as a tab-aware defaultSort() on the resource table rather than an orderBy() inside the tab's query scope: tab scopes run before sorting, so ordering there would make approved_at the primary sort and silently break column-header sorting. Every column is now toggleable and columns are reorderable, so admins can tailor the table. Filament keys the saved column state on the Livewire component class, and all four tabs share one component, so customisations carry across every tab and survive a reload. The logo column needed a real label because reorderableColumns() rejects blank labels. Co-Authored-By: Claude Opus 5 (1M context) --- app/Filament/Resources/PluginResource.php | 53 +++-- .../PluginResource/Pages/ListPlugins.php | 27 +++ .../Filament/GrantPluginToUserActionTest.php | 3 + .../Filament/PluginListDraftFilterTest.php | 2 + tests/Feature/Filament/PluginListTabsTest.php | 209 ++++++++++++++++++ tests/Feature/Filament/PluginResourceTest.php | 1 + 6 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 tests/Feature/Filament/PluginListTabsTest.php diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index 8e6abc0d..ebe1e130 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -22,6 +22,7 @@ use Filament\Schemas; use Filament\Schemas\Schema; use Filament\Tables; +use Filament\Tables\Contracts\HasTable; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\HtmlString; @@ -259,17 +260,19 @@ public static function table(Table $table): Table return $table ->columns([ Tables\Columns\ImageColumn::make('logo_path') - ->label('') + ->label('Logo') ->disk('public') ->circular() ->defaultImageUrl(fn () => 'https://ui-avatars.com/api/?name=P&color=7C3AED&background=EDE9FE') - ->size(40), + ->size(40) + ->toggleable(), Tables\Columns\TextColumn::make('name') ->label('Package Name') ->searchable() ->sortable() - ->fontFamily('mono'), + ->fontFamily('mono') + ->toggleable(), Tables\Columns\TextColumn::make('type') ->badge() @@ -277,18 +280,21 @@ public static function table(Table $table): Table PluginType::Free => 'gray', PluginType::Paid => 'success', }) - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\TextColumn::make('tier') ->badge() ->color(fn (?PluginTier $state): string => $state?->color() ?? 'gray') ->sortable() - ->placeholder('-'), + ->placeholder('-') + ->toggleable(), Tables\Columns\TextColumn::make('user.email') ->label('Submitted By') ->searchable() - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\TextColumn::make('status') ->badge() @@ -298,7 +304,8 @@ public static function table(Table $table): Table PluginStatus::Approved => 'success', PluginStatus::Rejected => 'danger', }) - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\TextColumn::make('mobile_min_version') ->label('Mobile SDK') @@ -307,25 +314,30 @@ public static function table(Table $table): Table Tables\Columns\ToggleColumn::make('is_official') ->label('Official') - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\ToggleColumn::make('featured') - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\ToggleColumn::make('is_active') ->label('Active') - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\ToggleColumn::make('works_in_jump') ->label('Jump') - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\IconColumn::make('reviewed_at') ->label('Reviewed') ->boolean() ->getStateUsing(fn (Plugin $record): bool => $record->reviewed_at !== null) ->tooltip(fn (Plugin $record): ?string => $record->reviewed_at?->diffForHumans()) - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\IconColumn::make('satis_synced_at') ->label('Satis') @@ -333,19 +345,23 @@ public static function table(Table $table): Table ->getStateUsing(fn (Plugin $record): bool => $record->isSatisSynced()) ->tooltip(fn (Plugin $record): ?string => $record->satis_synced_at?->diffForHumans()) ->visible(fn (): bool => true) - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\TextColumn::make('created_at') ->label('Submitted') ->dateTime() - ->sortable(), + ->sortable() + ->toggleable(), Tables\Columns\TextColumn::make('approved_at') ->label('Approved') ->dateTime() ->placeholder('-') - ->sortable(), + ->sortable() + ->toggleable(), ]) + ->reorderableColumns() ->filters([ Tables\Filters\SelectFilter::make('status') ->options(PluginStatus::class) @@ -507,7 +523,12 @@ public static function table(Table $table): Table ->bulkActions([ ]) - ->defaultSort('created_at', 'desc'); + ->defaultSort( + fn (HasTable $livewire): string => ($livewire instanceof Pages\ListPlugins && $livewire->getActiveTabStatus() === PluginStatus::Approved) + ? 'approved_at' + : 'created_at', + 'desc', + ); } public static function getRelations(): array diff --git a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php index ba97676c..c22ac682 100644 --- a/app/Filament/Resources/PluginResource/Pages/ListPlugins.php +++ b/app/Filament/Resources/PluginResource/Pages/ListPlugins.php @@ -2,9 +2,12 @@ namespace App\Filament\Resources\PluginResource\Pages; +use App\Enums\PluginStatus; use App\Filament\Resources\PluginResource; use Filament\Actions; use Filament\Resources\Pages\ListRecords; +use Filament\Schemas\Components\Tabs\Tab; +use Illuminate\Database\Eloquent\Builder; class ListPlugins extends ListRecords { @@ -16,4 +19,28 @@ protected function getHeaderActions(): array Actions\CreateAction::make(), ]; } + + public function getTabs(): array + { + return [ + PluginStatus::Pending->value => $this->makeStatusTab(PluginStatus::Pending, 'Pending'), + PluginStatus::Approved->value => $this->makeStatusTab(PluginStatus::Approved, 'Approved'), + PluginStatus::Rejected->value => $this->makeStatusTab(PluginStatus::Rejected, 'Rejected'), + 'all' => Tab::make('All'), + ]; + } + + /** + * The status the active tab is scoped to, or null when the tab lists every status. + */ + public function getActiveTabStatus(): ?PluginStatus + { + return PluginStatus::tryFrom($this->activeTab ?? ''); + } + + private function makeStatusTab(PluginStatus $status, string $label): Tab + { + return Tab::make($label) + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', $status)); + } } diff --git a/tests/Feature/Filament/GrantPluginToUserActionTest.php b/tests/Feature/Filament/GrantPluginToUserActionTest.php index 4dd650b6..f191de61 100644 --- a/tests/Feature/Filament/GrantPluginToUserActionTest.php +++ b/tests/Feature/Filament/GrantPluginToUserActionTest.php @@ -31,6 +31,7 @@ public function test_grant_to_user_action_is_hidden_for_free_plugins_on_list(): Livewire::actingAs($this->admin) ->test(ListPlugins::class) + ->set('activeTab', 'approved') ->assertTableActionHidden('grantToUser', $plugin); } @@ -40,6 +41,7 @@ public function test_grant_to_user_action_is_visible_for_paid_plugins_on_list(): Livewire::actingAs($this->admin) ->test(ListPlugins::class) + ->set('activeTab', 'approved') ->assertTableActionVisible('grantToUser', $plugin); } @@ -68,6 +70,7 @@ public function test_grant_to_user_action_can_be_called_with_user_id_on_list(): Livewire::actingAs($this->admin) ->test(ListPlugins::class) + ->set('activeTab', 'approved') ->callAction( TestAction::make('grantToUser')->table($plugin), data: ['user_id' => $recipient->id], diff --git a/tests/Feature/Filament/PluginListDraftFilterTest.php b/tests/Feature/Filament/PluginListDraftFilterTest.php index acb5cc10..b5078e58 100644 --- a/tests/Feature/Filament/PluginListDraftFilterTest.php +++ b/tests/Feature/Filament/PluginListDraftFilterTest.php @@ -31,6 +31,7 @@ public function test_draft_plugins_are_hidden_by_default(): void Livewire::actingAs($this->admin) ->test(ListPlugins::class) + ->set('activeTab', 'all') ->assertCanNotSeeTableRecords([$draft]) ->assertCanSeeTableRecords([$approved]); } @@ -42,6 +43,7 @@ public function test_draft_plugins_are_visible_when_filtering_by_draft_status(): Livewire::actingAs($this->admin) ->test(ListPlugins::class) + ->set('activeTab', 'all') ->filterTable('status', PluginStatus::Draft->value) ->assertCanSeeTableRecords([$draft]) ->assertCanNotSeeTableRecords([$approved]); diff --git a/tests/Feature/Filament/PluginListTabsTest.php b/tests/Feature/Filament/PluginListTabsTest.php new file mode 100644 index 00000000..472d13f9 --- /dev/null +++ b/tests/Feature/Filament/PluginListTabsTest.php @@ -0,0 +1,209 @@ +admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + } + + public function test_pending_is_the_default_tab(): void + { + $pending = Plugin::factory()->pending()->create(); + $approved = Plugin::factory()->approved()->create(); + $rejected = Plugin::factory()->rejected()->create(); + $draft = Plugin::factory()->draft()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertSet('activeTab', 'pending') + ->assertCanSeeTableRecords([$pending]) + ->assertCanNotSeeTableRecords([$approved, $rejected, $draft]); + } + + public function test_tabs_are_ordered_pending_approved_rejected_then_all(): void + { + $tabs = Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->instance() + ->getTabs(); + + $this->assertSame(['pending', 'approved', 'rejected', 'all'], array_keys($tabs)); + } + + public function test_list_page_renders_the_status_tabs(): void + { + Plugin::factory()->pending()->create(); + + $response = $this->actingAs($this->admin)->get(PluginResource::getUrl('index')); + + $response->assertOk(); + $response->assertSeeInOrder(['Pending', 'Approved', 'Rejected', 'All']); + } + + public function test_approved_tab_only_shows_approved_plugins(): void + { + $pending = Plugin::factory()->pending()->create(); + $approved = Plugin::factory()->approved()->create(); + $rejected = Plugin::factory()->rejected()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->set('activeTab', 'approved') + ->assertCanSeeTableRecords([$approved]) + ->assertCanNotSeeTableRecords([$pending, $rejected]); + } + + public function test_approved_tab_sorts_by_approval_date_in_reverse_chronological_order(): void + { + $oldest = Plugin::factory()->approved()->create([ + 'created_at' => now()->subDays(30), + 'approved_at' => now()->subDays(10), + ]); + $newest = Plugin::factory()->approved()->create([ + 'created_at' => now()->subDays(2), + 'approved_at' => now()->subDay(), + ]); + $middle = Plugin::factory()->approved()->create([ + 'created_at' => now()->subDays(20), + 'approved_at' => now()->subDays(5), + ]); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->set('activeTab', 'approved') + ->assertCanSeeTableRecords([$newest, $middle, $oldest], inOrder: true); + } + + public function test_other_tabs_sort_by_submission_date_in_reverse_chronological_order(): void + { + $oldest = Plugin::factory()->pending()->create(['created_at' => now()->subDays(10)]); + $newest = Plugin::factory()->pending()->create(['created_at' => now()->subDay()]); + $middle = Plugin::factory()->pending()->create(['created_at' => now()->subDays(5)]); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertCanSeeTableRecords([$newest, $middle, $oldest], inOrder: true); + } + + public function test_approved_tab_still_honours_a_column_sort_chosen_by_the_admin(): void + { + $first = Plugin::factory()->approved()->create([ + 'name' => 'acme/aaa-plugin', + 'approved_at' => now()->subDay(), + ]); + $last = Plugin::factory()->approved()->create([ + 'name' => 'acme/zzz-plugin', + 'approved_at' => now()->subDays(10), + ]); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->set('activeTab', 'approved') + ->sortTable('name', 'asc') + ->assertCanSeeTableRecords([$first, $last], inOrder: true) + ->sortTable('name', 'desc') + ->assertCanSeeTableRecords([$last, $first], inOrder: true); + } + + public function test_rejected_tab_only_shows_rejected_plugins(): void + { + $pending = Plugin::factory()->pending()->create(); + $approved = Plugin::factory()->approved()->create(); + $rejected = Plugin::factory()->rejected()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->set('activeTab', 'rejected') + ->assertCanSeeTableRecords([$rejected]) + ->assertCanNotSeeTableRecords([$pending, $approved]); + } + + public function test_all_tab_shows_every_reviewable_status(): void + { + $pending = Plugin::factory()->pending()->create(); + $approved = Plugin::factory()->approved()->create(); + $rejected = Plugin::factory()->rejected()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->set('activeTab', 'all') + ->assertCanSeeTableRecords([$pending, $approved, $rejected]); + } + + public function test_hidden_columns_stay_hidden_when_switching_tabs(): void + { + Plugin::factory()->pending()->create(); + Plugin::factory()->approved()->create(); + + $component = Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertCanRenderTableColumn('featured'); + + $component + ->call('applyTableColumnManager', $this->hideColumn($component->get('tableColumns'), 'featured')) + ->assertCanNotRenderTableColumn('featured') + ->set('activeTab', 'approved') + ->assertCanNotRenderTableColumn('featured') + ->set('activeTab', 'all') + ->assertCanNotRenderTableColumn('featured'); + } + + public function test_hidden_columns_are_remembered_on_a_later_visit(): void + { + Plugin::factory()->pending()->create(); + + $component = Livewire::actingAs($this->admin)->test(ListPlugins::class); + + $component->call('applyTableColumnManager', $this->hideColumn($component->get('tableColumns'), 'featured')); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertCanNotRenderTableColumn('featured') + ->assertCanRenderTableColumn('name'); + } + + public function test_column_customisations_can_be_reset(): void + { + Plugin::factory()->pending()->create(); + + $component = Livewire::actingAs($this->admin)->test(ListPlugins::class); + + $component + ->call('applyTableColumnManager', $this->hideColumn($component->get('tableColumns'), 'featured')) + ->assertCanNotRenderTableColumn('featured') + ->call('resetTableColumnManager') + ->assertCanRenderTableColumn('featured'); + } + + /** + * @param array> $columns + * @return array> + */ + private function hideColumn(array $columns, string $name): array + { + return array_map( + fn (array $column): array => $column['name'] === $name + ? [...$column, 'isToggled' => false] + : $column, + $columns, + ); + } +} diff --git a/tests/Feature/Filament/PluginResourceTest.php b/tests/Feature/Filament/PluginResourceTest.php index 514efbbe..df9aa858 100644 --- a/tests/Feature/Filament/PluginResourceTest.php +++ b/tests/Feature/Filament/PluginResourceTest.php @@ -138,6 +138,7 @@ public function test_plugins_table_can_be_sorted_by_approved_at(): void Livewire::actingAs($this->admin) ->test(ListPlugins::class) + ->set('activeTab', 'all') ->assertCanRenderTableColumn('approved_at') ->sortTable('approved_at', 'desc') ->assertCanSeeTableRecords([$newest, $middle, $oldest], inOrder: true) From d77ab680539d3aa8496aa17e65f65178d45a7936 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 25 Aug 2026 10:40:41 +0100 Subject: [PATCH 2/2] Replace the plugin status filter with a Show drafts checkbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status tabs now cover pending, approved and rejected, so the status select filter was redundant — and picking a status that contradicted the active tab produced an empty table. All it still did was gate access to drafts, so it becomes a single "Show drafts" checkbox. The checkbox is given to the filter as a schema rather than using Filament's built-in isActive checkbox. A plain Filter only applies its query() when checked, but we need the opposite: drafts are excluded unless the box is ticked. With a schema there is no isActive key, so the query callback always runs and reads the checkbox state itself. Co-Authored-By: Claude Opus 5 (1M context) --- app/Filament/Resources/PluginResource.php | 15 +++++-- .../Filament/PluginListDraftFilterTest.php | 45 +++++++++++++++++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/app/Filament/Resources/PluginResource.php b/app/Filament/Resources/PluginResource.php index ebe1e130..e1cec071 100644 --- a/app/Filament/Resources/PluginResource.php +++ b/app/Filament/Resources/PluginResource.php @@ -363,11 +363,18 @@ public static function table(Table $table): Table ]) ->reorderableColumns() ->filters([ - Tables\Filters\SelectFilter::make('status') - ->options(PluginStatus::class) - ->query(fn (Builder $query, array $data): Builder => filled($data['value']) - ? $query->where('status', $data['value']) + Tables\Filters\Filter::make('drafts') + ->schema([ + Forms\Components\Checkbox::make('show_drafts') + ->label('Show drafts'), + ]) + ->query(fn (Builder $query, array $data): Builder => ($data['show_drafts'] ?? false) + ? $query : $query->where('status', '!=', PluginStatus::Draft) + ) + ->indicateUsing(fn (array $data): array => ($data['show_drafts'] ?? false) + ? ['show_drafts' => 'Showing drafts'] + : [] ), Tables\Filters\SelectFilter::make('type') ->options(PluginType::class), diff --git a/tests/Feature/Filament/PluginListDraftFilterTest.php b/tests/Feature/Filament/PluginListDraftFilterTest.php index b5078e58..a1e087bb 100644 --- a/tests/Feature/Filament/PluginListDraftFilterTest.php +++ b/tests/Feature/Filament/PluginListDraftFilterTest.php @@ -2,7 +2,6 @@ namespace Tests\Feature\Filament; -use App\Enums\PluginStatus; use App\Filament\Resources\PluginResource\Pages\ListPlugins; use App\Models\Plugin; use App\Models\User; @@ -24,6 +23,18 @@ protected function setUp(): void config(['filament.users' => ['admin@test.com']]); } + public function test_show_drafts_checkbox_renders_and_no_status_filter_remains(): void + { + Plugin::factory()->pending()->create(); + + $component = Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->assertTableFilterExists('drafts') + ->assertSee('Show drafts'); + + $this->assertNull($component->instance()->getTable()->getFilter('status')); + } + public function test_draft_plugins_are_hidden_by_default(): void { $draft = Plugin::factory()->draft()->create(); @@ -36,7 +47,19 @@ public function test_draft_plugins_are_hidden_by_default(): void ->assertCanSeeTableRecords([$approved]); } - public function test_draft_plugins_are_visible_when_filtering_by_draft_status(): void + public function test_draft_plugins_are_visible_when_show_drafts_is_checked(): void + { + $draft = Plugin::factory()->draft()->create(); + $approved = Plugin::factory()->approved()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->set('activeTab', 'all') + ->filterTable('drafts', ['show_drafts' => true]) + ->assertCanSeeTableRecords([$draft, $approved]); + } + + public function test_drafts_are_hidden_again_when_show_drafts_is_unchecked(): void { $draft = Plugin::factory()->draft()->create(); $approved = Plugin::factory()->approved()->create(); @@ -44,8 +67,22 @@ public function test_draft_plugins_are_visible_when_filtering_by_draft_status(): Livewire::actingAs($this->admin) ->test(ListPlugins::class) ->set('activeTab', 'all') - ->filterTable('status', PluginStatus::Draft->value) + ->filterTable('drafts', ['show_drafts' => true]) ->assertCanSeeTableRecords([$draft]) - ->assertCanNotSeeTableRecords([$approved]); + ->filterTable('drafts', ['show_drafts' => false]) + ->assertCanNotSeeTableRecords([$draft]) + ->assertCanSeeTableRecords([$approved]); + } + + public function test_show_drafts_does_not_leak_drafts_into_the_status_tabs(): void + { + $draft = Plugin::factory()->draft()->create(); + $pending = Plugin::factory()->pending()->create(); + + Livewire::actingAs($this->admin) + ->test(ListPlugins::class) + ->filterTable('drafts', ['show_drafts' => true]) + ->assertCanSeeTableRecords([$pending]) + ->assertCanNotSeeTableRecords([$draft]); } }