## Summary - unify empty-state UX across the six in-scope Filament list pages - move empty-state ownership toward resource `table()` definitions while preserving existing RBAC behavior - add focused Pest coverage for empty-state rendering, CTA outcomes, populated-state regression behavior, and action-surface compliance - add the Spec 122 planning artifacts and product discovery documents used for this pass ## Changed surfaces - `PolicyResource` - `BackupSetResource` - `RestoreRunResource` - `BackupScheduleResource` - `WorkspaceResource` - `AlertDeliveryResource` ## Tests - `vendor/bin/sail artisan test --compact tests/Feature/Filament/EmptyStateConsistencyTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/Filament/CreateCtaPlacementTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/PolicySyncStartSurfaceTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetUiEnforcementTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/Filament/RestoreRunUiEnforcementTest.php` - `vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php` - `vendor/bin/sail bin pint --dirty --format agent` ## Notes - Filament v5 / Livewire v4.0+ compliance is preserved. - Panel provider registration remains unchanged in `bootstrap/providers.php`. - No new globally searchable resources were added. - Destructive actions were not introduced by this pass. - Alert Deliveries is documented as the explicit no-header-action exemption for the empty-state CTA relocation rule. - Manual light/dark visual QA evidence is still expected in the PR/review artifact set for the remaining checklist items (`T018`, `T025`). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #148
183 lines
6.2 KiB
PHP
183 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Resources\AlertDeliveryResource;
|
|
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
|
use App\Filament\Resources\AlertRuleResource;
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\AlertDestination;
|
|
use App\Models\AlertRule;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use Filament\Actions\Action;
|
|
use Livewire\Features\SupportTesting\Testable;
|
|
use Livewire\Livewire;
|
|
|
|
function getAlertDeliveryEmptyStateAction(Testable $component, string $name): ?Action
|
|
{
|
|
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
|
|
if ($action instanceof Action && $action->getName() === $name) {
|
|
return $action;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Action
|
|
{
|
|
$instance = $component->instance();
|
|
$instance->cacheInteractsWithHeaderActions();
|
|
|
|
foreach ($instance->getCachedHeaderActions() as $action) {
|
|
if ($action instanceof Action && $action->getName() === $name) {
|
|
return $action;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
it('lists only deliveries for entitled tenants', function (): void {
|
|
[$user, $tenantA] = createUserWithTenant(role: 'readonly');
|
|
|
|
$tenantB = Tenant::factory()->create([
|
|
'workspace_id' => (int) $tenantA->workspace_id,
|
|
]);
|
|
|
|
$workspaceId = (int) $tenantA->workspace_id;
|
|
|
|
$rule = AlertRule::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
|
|
$destination = AlertDestination::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
|
|
$tenantADelivery = AlertDelivery::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenantA->getKey(),
|
|
'alert_rule_id' => (int) $rule->getKey(),
|
|
'alert_destination_id' => (int) $destination->getKey(),
|
|
'event_type' => 'high_drift',
|
|
]);
|
|
|
|
$tenantBDelivery = AlertDelivery::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenantB->getKey(),
|
|
'alert_rule_id' => (int) $rule->getKey(),
|
|
'alert_destination_id' => (int) $destination->getKey(),
|
|
'event_type' => 'compare_failed',
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
|
|
Livewire::test(ListAlertDeliveries::class)
|
|
->assertCanSeeTableRecords([$tenantADelivery])
|
|
->assertCanNotSeeTableRecords([$tenantBDelivery]);
|
|
});
|
|
|
|
it('shows a guided empty state on alert deliveries and links to alert rules', function (): void {
|
|
[$user] = createUserWithTenant(role: 'owner');
|
|
|
|
$this->actingAs($user);
|
|
|
|
$component = Livewire::test(ListAlertDeliveries::class)
|
|
->assertTableEmptyStateActionsExistInOrder(['view_alert_rules'])
|
|
->assertSee('No alert deliveries')
|
|
->assertSee('Deliveries appear automatically when alert rules fire.');
|
|
|
|
$table = $component->instance()->getTable();
|
|
|
|
expect($table->getEmptyStateHeading())->toBe('No alert deliveries');
|
|
expect($table->getEmptyStateDescription())->toBe('Deliveries appear automatically when alert rules fire.');
|
|
expect($table->getEmptyStateIcon())->toBe('heroicon-o-bell-alert');
|
|
|
|
$action = getAlertDeliveryEmptyStateAction($component, 'view_alert_rules');
|
|
|
|
expect($action)->not->toBeNull();
|
|
expect($action?->getLabel())->toBe('View alert rules');
|
|
expect($action?->getUrl())->toBe(AlertRuleResource::getUrl(panel: 'admin'));
|
|
expect(getAlertDeliveryHeaderAction($component, 'view_alert_rules'))->toBeNull();
|
|
});
|
|
|
|
it('keeps alert deliveries header-action free after records exist', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
|
|
$workspaceId = (int) $tenant->workspace_id;
|
|
|
|
$rule = AlertRule::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
|
|
$destination = AlertDestination::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
|
|
$delivery = AlertDelivery::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'alert_rule_id' => (int) $rule->getKey(),
|
|
'alert_destination_id' => (int) $destination->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user);
|
|
|
|
$component = Livewire::test(ListAlertDeliveries::class)
|
|
->assertCanSeeTableRecords([$delivery]);
|
|
|
|
expect(getAlertDeliveryHeaderAction($component, 'view_alert_rules'))->toBeNull();
|
|
});
|
|
|
|
it('returns 404 when a member from another workspace tries to view a delivery', function (): void {
|
|
[$user] = createUserWithTenant(role: 'owner');
|
|
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
$tenant = Tenant::factory()->create([
|
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
|
]);
|
|
$rule = AlertRule::factory()->create([
|
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
|
]);
|
|
$destination = AlertDestination::factory()->create([
|
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
|
]);
|
|
$delivery = AlertDelivery::factory()->create([
|
|
'workspace_id' => (int) $otherWorkspace->getKey(),
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'alert_rule_id' => (int) $rule->getKey(),
|
|
'alert_destination_id' => (int) $destination->getKey(),
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->get(AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'))
|
|
->assertNotFound();
|
|
});
|
|
|
|
it('returns 403 for members missing alerts view capability on deliveries index', function (): void {
|
|
$workspace = Workspace::factory()->create();
|
|
$user = User::factory()->create();
|
|
|
|
WorkspaceMembership::factory()->create([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'user_id' => (int) $user->getKey(),
|
|
'role' => 'readonly',
|
|
]);
|
|
|
|
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
$resolver->shouldReceive('can')->andReturnFalse();
|
|
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
|
|
|
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
$this->actingAs($user)
|
|
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
|
->assertForbidden();
|
|
});
|