From 7876e91b726a40fb1843da4cf6320d08fa474e15 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 14 Feb 2026 14:43:41 +0100 Subject: [PATCH] feat(filament): place create CTA by emptiness --- .../Pages/ListBackupSchedules.php | 20 +- .../Pages/ListBackupSets.php | 13 + .../Pages/ListProviderConnections.php | 87 +++++- .../Pages/ListRestoreRuns.php | 13 + .../TenantResource/Pages/ListTenants.php | 9 +- .../Workspaces/Pages/ListWorkspaces.php | 20 +- tests/Feature/090/EmptyStateCtasTest.php | 81 ++++- .../Filament/BackupSetUiEnforcementTest.php | 26 +- .../Filament/CreateCtaPlacementTest.php | 278 ++++++++++++++++++ 9 files changed, 527 insertions(+), 20 deletions(-) create mode 100644 tests/Feature/Filament/CreateCtaPlacementTest.php diff --git a/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php b/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php index 1a20a1b..34842d4 100644 --- a/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php +++ b/app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php @@ -12,12 +12,28 @@ class ListBackupSchedules extends ListRecords protected function getHeaderActions(): array { - return [$this->makeCreateAction()]; + return [$this->makeHeaderCreateAction()]; } protected function getTableEmptyStateActions(): array { - return [$this->makeCreateAction()]; + return [$this->makeEmptyStateCreateAction()]; + } + + private function tableHasRecords(): bool + { + return $this->getTableRecords()->count() > 0; + } + + private function makeHeaderCreateAction(): Actions\CreateAction + { + return $this->makeCreateAction() + ->visible(fn (): bool => $this->tableHasRecords()); + } + + private function makeEmptyStateCreateAction(): Actions\CreateAction + { + return $this->makeCreateAction(); } private function makeCreateAction(): Actions\CreateAction diff --git a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php index afd21e5..aca7057 100644 --- a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php +++ b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php @@ -12,7 +12,20 @@ class ListBackupSets extends ListRecords { protected static string $resource = BackupSetResource::class; + private function tableHasRecords(): bool + { + return $this->getTableRecords()->count() > 0; + } + protected function getHeaderActions(): array + { + return [ + UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()) + ->visible(fn (): bool => $this->tableHasRecords()), + ]; + } + + protected function getTableEmptyStateActions(): array { return [ UiEnforcement::for(Capabilities::TENANT_SYNC)->apply(Actions\CreateAction::make()), diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index 8dc5ea4..ee33227 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -15,6 +15,11 @@ class ListProviderConnections extends ListRecords { protected static string $resource = ProviderConnectionResource::class; + private function tableHasRecords(): bool + { + return $this->getTableRecords()->count() > 0; + } + protected function getHeaderActions(): array { /** @var CapabilityResolver $resolver */ @@ -35,6 +40,10 @@ protected function getHeaderActions(): array ]); }) ->visible(function () use ($resolver): bool { + if (! $this->tableHasRecords()) { + return false; + } + $tenant = $this->resolveTenantForCreateAction(); $user = auth()->user(); @@ -93,6 +102,82 @@ protected function getHeaderActions(): array ]; } + private function makeEmptyStateCreateAction(): Actions\CreateAction + { + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + return Actions\CreateAction::make() + ->label('New connection') + ->url(function (): string { + $tenantExternalId = $this->resolveTenantExternalIdForCreateAction(); + + if (! is_string($tenantExternalId) || $tenantExternalId === '') { + return ProviderConnectionResource::getUrl('create'); + } + + return ProviderConnectionResource::getUrl('create', [ + 'tenant_id' => $tenantExternalId, + ]); + }) + ->visible(function () use ($resolver): bool { + $tenant = $this->resolveTenantForCreateAction(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + return true; + } + + if (! $user instanceof User) { + return false; + } + + return $resolver->isMember($user, $tenant); + }) + ->disabled(function () use ($resolver): bool { + $tenant = $this->resolveTenantForCreateAction(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant) { + return true; + } + + if (! $user instanceof User) { + return true; + } + + if (! $resolver->isMember($user, $tenant)) { + return true; + } + + return ! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE); + }) + ->tooltip(function () use ($resolver): ?string { + $tenant = $this->resolveTenantForCreateAction(); + + if (! $tenant instanceof Tenant) { + return 'Select a tenant to create provider connections.'; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + if (! $resolver->isMember($user, $tenant)) { + return null; + } + + if (! $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE)) { + return 'You do not have permission to create provider connections.'; + } + + return null; + }) + ->authorize(fn (): bool => true); + } + private function resolveTenantExternalIdForCreateAction(): ?string { $filterValue = data_get($this->getTableFilterState('tenant'), 'value'); @@ -138,6 +223,6 @@ public function getTableEmptyStateDescription(): ?string public function getTableEmptyStateActions(): array { - return $this->getHeaderActions(); + return [$this->makeEmptyStateCreateAction()]; } } diff --git a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php index fb11d2e..ec9a501 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php @@ -12,7 +12,20 @@ class ListRestoreRuns extends ListRecords { protected static string $resource = RestoreRunResource::class; + private function tableHasRecords(): bool + { + return $this->getTableRecords()->count() > 0; + } + protected function getHeaderActions(): array + { + return [ + UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()) + ->visible(fn (): bool => $this->tableHasRecords()), + ]; + } + + protected function getTableEmptyStateActions(): array { return [ UiEnforcement::for(Capabilities::TENANT_MANAGE)->apply(Actions\CreateAction::make()), diff --git a/app/Filament/Resources/TenantResource/Pages/ListTenants.php b/app/Filament/Resources/TenantResource/Pages/ListTenants.php index f532cb3..ebb01d8 100644 --- a/app/Filament/Resources/TenantResource/Pages/ListTenants.php +++ b/app/Filament/Resources/TenantResource/Pages/ListTenants.php @@ -15,12 +15,17 @@ protected function getHeaderActions(): array return [ Actions\CreateAction::make() ->disabled(fn (): bool => ! TenantResource::canCreate()) - ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'), + ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.') + ->visible(fn (): bool => $this->getTableRecords()->count() > 0), ]; } protected function getTableEmptyStateActions(): array { - return $this->getHeaderActions(); + return [ + Actions\CreateAction::make() + ->disabled(fn (): bool => ! TenantResource::canCreate()) + ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'), + ]; } } diff --git a/app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php b/app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php index a454a9c..01d4e2d 100644 --- a/app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php +++ b/app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php @@ -12,12 +12,28 @@ class ListWorkspaces extends ListRecords protected function getHeaderActions(): array { - return [$this->makeCreateAction()]; + return [$this->makeHeaderCreateAction()]; } protected function getTableEmptyStateActions(): array { - return [$this->makeCreateAction()]; + return [$this->makeEmptyStateCreateAction()]; + } + + private function tableHasRecords(): bool + { + return $this->getTableRecords()->count() > 0; + } + + private function makeHeaderCreateAction(): Actions\CreateAction + { + return $this->makeCreateAction() + ->visible(fn (): bool => $this->tableHasRecords()); + } + + private function makeEmptyStateCreateAction(): Actions\CreateAction + { + return $this->makeCreateAction(); } private function makeCreateAction(): Actions\CreateAction diff --git a/tests/Feature/090/EmptyStateCtasTest.php b/tests/Feature/090/EmptyStateCtasTest.php index b80b6d1..c1dce6b 100644 --- a/tests/Feature/090/EmptyStateCtasTest.php +++ b/tests/Feature/090/EmptyStateCtasTest.php @@ -3,13 +3,16 @@ declare(strict_types=1); use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; +use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns; use App\Filament\Resources\Workspaces\Pages\ListWorkspaces; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Action; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; use Tests\TestCase; @@ -17,6 +20,17 @@ final class EmptyStateCtasTest extends TestCase { use RefreshDatabase; + private function getTableEmptyStateAction(Testable $component, string $name): ?Action + { + foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) { + if ($action instanceof Action && $action->getName() === $name) { + return $action; + } + } + + return null; + } + public function test_spec090_shows_workspace_empty_state_create_cta_for_users_with_workspace_manage(): void { $workspace = Workspace::factory()->create([ @@ -38,10 +52,13 @@ public function test_spec090_shows_workspace_empty_state_create_cta_for_users_wi $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); - Livewire::test(ListWorkspaces::class) - ->assertTableEmptyStateActionsExistInOrder(['create']) - ->assertActionVisible('create') - ->assertActionEnabled('create'); + $component = Livewire::test(ListWorkspaces::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $action = $this->getTableEmptyStateAction($component, 'create'); + $this->assertNotNull($action); + $this->assertTrue($action->isVisible()); + $this->assertFalse($action->isDisabled()); } public function test_spec090_disables_workspace_empty_state_create_cta_without_workspace_manage(): void @@ -65,10 +82,13 @@ public function test_spec090_disables_workspace_empty_state_create_cta_without_w $this->actingAs($user) ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); - Livewire::test(ListWorkspaces::class) - ->assertTableEmptyStateActionsExistInOrder(['create']) - ->assertActionVisible('create') - ->assertActionDisabled('create'); + $component = Livewire::test(ListWorkspaces::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $action = $this->getTableEmptyStateAction($component, 'create'); + $this->assertNotNull($action); + $this->assertTrue($action->isVisible()); + $this->assertTrue($action->isDisabled()); } public function test_spec090_shows_backup_schedule_empty_state_create_cta_for_users_with_manage_capability(): void @@ -79,10 +99,47 @@ public function test_spec090_shows_backup_schedule_empty_state_create_cta_for_us $tenant->makeCurrent(); Filament::setTenant($tenant, true); - Livewire::test(ListBackupSchedules::class) - ->assertTableEmptyStateActionsExistInOrder(['create']) - ->assertActionVisible('create') - ->assertActionEnabled('create'); + $component = Livewire::test(ListBackupSchedules::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $action = $this->getTableEmptyStateAction($component, 'create'); + $this->assertNotNull($action); + $this->assertTrue($action->isVisible()); + $this->assertFalse($action->isDisabled()); + } + + public function test_spec090_shows_restore_run_empty_state_create_cta_for_users_with_manage_capability(): void + { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListRestoreRuns::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $action = $this->getTableEmptyStateAction($component, 'create'); + $this->assertNotNull($action); + $this->assertTrue($action->isVisible()); + $this->assertFalse($action->isDisabled()); + } + + public function test_spec090_disables_restore_run_empty_state_create_cta_without_manage_capability(): void + { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListRestoreRuns::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $action = $this->getTableEmptyStateAction($component, 'create'); + $this->assertNotNull($action); + $this->assertTrue($action->isVisible()); + $this->assertTrue($action->isDisabled()); } public function test_spec090_disables_backup_schedule_empty_state_create_cta_without_manage_capability(): void diff --git a/tests/Feature/Filament/BackupSetUiEnforcementTest.php b/tests/Feature/Filament/BackupSetUiEnforcementTest.php index 5dcb540..6e6477f 100644 --- a/tests/Feature/Filament/BackupSetUiEnforcementTest.php +++ b/tests/Feature/Filament/BackupSetUiEnforcementTest.php @@ -4,7 +4,6 @@ use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets; use App\Models\BackupSet; use App\Models\Tenant; -use App\Models\User; use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -69,3 +68,28 @@ expect($backupSet->fresh()->trashed())->toBeTrue(); }); +test('backup sets list shows empty state create action enabled for members with sync capability', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'owner'); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListBackupSets::class) + ->assertTableEmptyStateActionsExistInOrder(['create']) + ->assertActionVisible('create') + ->assertActionEnabled('create'); +}); + +test('backup sets list shows empty state create action disabled for members without sync capability', function () { + $tenant = Tenant::factory()->create(); + [$user] = createUserWithTenant($tenant, role: 'readonly'); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListBackupSets::class) + ->assertTableEmptyStateActionsExistInOrder(['create']) + ->assertActionVisible('create') + ->assertActionDisabled('create'); +}); diff --git a/tests/Feature/Filament/CreateCtaPlacementTest.php b/tests/Feature/Filament/CreateCtaPlacementTest.php new file mode 100644 index 0000000..dfff483 --- /dev/null +++ b/tests/Feature/Filament/CreateCtaPlacementTest.php @@ -0,0 +1,278 @@ +instance(); + $instance->cacheInteractsWithHeaderActions(); + + foreach ($instance->getCachedHeaderActions() as $action) { + if ($action instanceof Action && $action->getName() === $name) { + return $action; + } + } + + return null; +} + +it('shows create only in empty state when workspaces table is empty', function (): void { + $workspace = Workspace::factory()->create([ + 'archived_at' => now(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill([ + 'last_workspace_id' => (int) $workspace->getKey(), + ])->save(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); + + $component = Livewire::test(ListWorkspaces::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeFalse(); +}); + +it('shows create only in header when workspaces table is not empty', function (): void { + $workspace = Workspace::factory()->create([ + 'archived_at' => null, + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill([ + 'last_workspace_id' => (int) $workspace->getKey(), + ])->save(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); + + $component = Livewire::test(ListWorkspaces::class); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeTrue(); +}); + +it('shows create only in empty state when backup schedules table is empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListBackupSchedules::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeFalse(); +}); + +it('shows create only in header when backup schedules table is not empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + BackupSchedule::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'name' => 'Daily schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '00:00:00', + 'days_of_week' => null, + 'policy_types' => ['device_config'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $component = Livewire::test(ListBackupSchedules::class); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeTrue(); +}); + +it('shows create only in empty state when restore runs table is empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListRestoreRuns::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeFalse(); +}); + +it('shows create only in header when restore runs table is not empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $component = Livewire::test(ListRestoreRuns::class); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeTrue(); +}); + +it('shows create only in empty state when backup sets table is empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListBackupSets::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeFalse(); +}); + +it('shows create only in header when backup sets table is not empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $component = Livewire::test(ListBackupSets::class); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeTrue(); +}); + +it('shows create only in empty state when tenants table is empty', function (): void { + $workspace = Workspace::factory()->create([ + 'archived_at' => now(), + ]); + + $user = User::factory()->create(); + + WorkspaceMembership::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'user_id' => (int) $user->getKey(), + 'role' => 'owner', + ]); + + $user->forceFill([ + 'last_workspace_id' => (int) $workspace->getKey(), + ])->save(); + + $this->actingAs($user) + ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); + + $component = Livewire::test(ListTenants::class) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeFalse(); +}); + +it('shows create only in header when tenants table is not empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $component = Livewire::test(ListTenants::class) + ->assertCountTableRecords(1); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeTrue(); +}); + +it('shows create only in empty state when provider connections table is empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListProviderConnections::class) + ->assertCountTableRecords(0) + ->assertTableEmptyStateActionsExistInOrder(['create']); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeFalse(); +}); + +it('shows create only in header when provider connections table is not empty', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + ProviderConnection::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $component = Livewire::test(ListProviderConnections::class); + + $headerCreate = getHeaderAction($component, 'create'); + expect($headerCreate)->not->toBeNull(); + expect($headerCreate?->isVisible())->toBeTrue(); +});