Spec 122: Empty state consistency pass (#148)

## 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
This commit is contained in:
ahmido 2026-03-08 02:17:51 +00:00
parent 891f177311
commit 73a3a62451
32 changed files with 2239 additions and 190 deletions

View File

@ -44,6 +44,8 @@ ## Active Technologies
- PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables) (120-secret-redaction-integrity) - PostgreSQL (`policy_versions`, `operation_runs`, `audit_logs`, related evidence tables) (120-secret-redaction-integrity)
- PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4 (121-workspace-switch-fix) - PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4 (121-workspace-switch-fix)
- PostgreSQL + session-backed workspace context; no schema changes (121-workspace-switch-fix) - PostgreSQL + session-backed workspace context; no schema changes (121-workspace-switch-fix)
- PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4 (122-empty-state-consistency)
- PostgreSQL + existing workspace/tenant session context; no schema changes (122-empty-state-consistency)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -63,8 +65,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 122-empty-state-consistency: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4
- 121-workspace-switch-fix: Added PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4 - 121-workspace-switch-fix: Added PHP 8.4.15 / Laravel 12 + Filament v5 + Livewire v4.0+ + Tailwind CSS v4
- 120-secret-redaction-integrity: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail - 120-secret-redaction-integrity: Added PHP 8.4, Laravel 12, Filament v5, Livewire v4 + Laravel framework, Filament admin panels, Livewire, PostgreSQL JSONB persistence, Laravel Sail
- 116-baseline-drift-engine-session-1772451227: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -18,6 +18,7 @@
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
@ -92,10 +93,20 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.') ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are exposed for read-only deliveries.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.') ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk actions are exposed for read-only deliveries.')
->exempt(ActionSurfaceSlot::ListEmptyState, 'Deliveries are generated by jobs and intentionally have no empty-state CTA.') ->satisfy(ActionSurfaceSlot::ListEmptyState, 'Guided empty state links to View alert rules.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.'); ->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational with no mutating header actions.');
} }
public static function makeViewAlertRulesAction(): Action
{
return Action::make('view_alert_rules')
->label('View alert rules')
->icon('heroicon-o-funnel')
->color('primary')
->button()
->url(AlertRuleResource::getUrl(panel: 'admin'));
}
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
@ -270,7 +281,13 @@ public static function table(Table $table): Table
->actions([ ->actions([
ViewAction::make()->label('View'), ViewAction::make()->label('View'),
]) ])
->bulkActions([]); ->bulkActions([])
->emptyStateHeading('No alert deliveries')
->emptyStateDescription('Deliveries appear automatically when alert rules fire.')
->emptyStateIcon('heroicon-o-bell-alert')
->emptyStateActions([
static::makeViewAlertRulesAction(),
]);
} }
public static function getPages(): array public static function getPages(): array

View File

@ -35,6 +35,7 @@
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
use Filament\Actions\BulkAction; use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Forms\Components\CheckboxList; use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
@ -253,6 +254,16 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function makeCreateAction(): CreateAction
{
return CreateAction::make()
->label('New backup schedule')
->disabled(fn (): bool => ! static::canCreate())
->tooltip(fn (): ?string => static::canCreate()
? null
: 'You do not have permission to create backup schedules.');
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@ -875,6 +886,12 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN) ->requireCapability(Capabilities::TENANT_BACKUP_SCHEDULES_RUN)
->apply(), ->apply(),
])->label('More'), ])->label('More'),
])
->emptyStateHeading('No schedules configured')
->emptyStateDescription('Set up automated backups.')
->emptyStateIcon('heroicon-o-clock')
->emptyStateActions([
static::makeCreateAction(),
]); ]);
} }

View File

@ -3,7 +3,6 @@
namespace App\Filament\Resources\BackupScheduleResource\Pages; namespace App\Filament\Resources\BackupScheduleResource\Pages;
use App\Filament\Resources\BackupScheduleResource; use App\Filament\Resources\BackupScheduleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListBackupSchedules extends ListRecords class ListBackupSchedules extends ListRecords
@ -12,37 +11,14 @@ class ListBackupSchedules extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [$this->makeHeaderCreateAction()]; return [
} BackupScheduleResource::makeCreateAction()
->visible(fn (): bool => $this->tableHasRecords()),
protected function getTableEmptyStateActions(): array ];
{
return [$this->makeEmptyStateCreateAction()];
} }
private function tableHasRecords(): bool private function tableHasRecords(): bool
{ {
return $this->getTableRecords()->count() > 0; 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
{
return Actions\CreateAction::make()
->label('New backup schedule')
->disabled(fn (): bool => ! BackupScheduleResource::canCreate())
->tooltip(fn (): ?string => BackupScheduleResource::canCreate()
? null
: 'You do not have permission to create backup schedules.');
}
} }

View File

@ -104,6 +104,18 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function makeCreateAction(): Actions\CreateAction
{
$action = Actions\CreateAction::make()
->label('Create backup set');
UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return $action;
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@ -483,6 +495,12 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_DELETE) ->requireCapability(Capabilities::TENANT_DELETE)
->apply(), ->apply(),
])->label('More'), ])->label('More'),
])
->emptyStateHeading('No backup sets')
->emptyStateDescription('Create a backup set to start protecting your configurations.')
->emptyStateIcon('heroicon-o-archive-box')
->emptyStateActions([
static::makeCreateAction(),
]); ]);
} }

View File

@ -3,9 +3,6 @@
namespace App\Filament\Resources\BackupSetResource\Pages; namespace App\Filament\Resources\BackupSetResource\Pages;
use App\Filament\Resources\BackupSetResource; use App\Filament\Resources\BackupSetResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListBackupSets extends ListRecords class ListBackupSets extends ListRecords
@ -19,25 +16,9 @@ private function tableHasRecords(): bool
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return [ return [
$create->visible(fn (): bool => $this->tableHasRecords()), BackupSetResource::makeCreateAction()
]; ->visible(fn (): bool => $this->tableHasRecords()),
}
protected function getTableEmptyStateActions(): array
{
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_SYNC)
->apply();
return [
$create,
]; ];
} }
} }

View File

@ -86,6 +86,74 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.'); ->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
} }
public static function makeSyncAction(string $name = 'sync'): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make($name)
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalHeading('Sync policies from Intune')
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
->action(function (Pages\ListPolicies $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
sort($requestedTypes);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
->apply();
}
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -908,6 +976,12 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_MANAGE) ->requireCapability(Capabilities::TENANT_MANAGE)
->apply(), ->apply(),
])->label('More'), ])->label('More'),
])
->emptyStateHeading('No policies synced yet')
->emptyStateDescription('Sync your first tenant to see Intune policies here.')
->emptyStateIcon('heroicon-o-arrow-path')
->emptyStateActions([
static::makeSyncAction(),
]); ]);
} }

View File

@ -3,16 +3,6 @@
namespace App\Filament\Resources\PolicyResource\Pages; namespace App\Filament\Resources\PolicyResource\Pages;
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListPolicies extends ListRecords class ListPolicies extends ListRecords
@ -22,80 +12,7 @@ class ListPolicies extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
$this->makeSyncAction(), PolicyResource::makeSyncAction(),
]; ];
} }
protected function getTableEmptyStateActions(): array
{
return [$this->makeSyncAction()];
}
private function makeSyncAction(string $name = 'sync'): Actions\Action
{
return UiEnforcement::forAction(
Actions\Action::make($name)
->label('Sync from Intune')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->modalHeading('Sync policies from Intune')
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
->action(function (self $livewire): void {
$tenant = Tenant::current();
$user = auth()->user();
if (! $user instanceof User || ! $tenant instanceof Tenant) {
abort(404);
}
$requestedTypes = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
config('tenantpilot.supported_policy_types', [])
);
sort($requestedTypes);
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: [
'scope' => 'all',
'types' => $requestedTypes,
],
initiator: $user
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
});
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
)
->requireCapability(Capabilities::TENANT_SYNC)
->tooltip('You do not have permission to sync policies.')
->apply();
}
} }

View File

@ -222,6 +222,18 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function makeCreateAction(): Actions\CreateAction
{
$action = Actions\CreateAction::make()
->label('New restore run');
UiEnforcement::forAction($action)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return $action;
}
/** /**
* @return array<int, Step> * @return array<int, Step>
*/ */
@ -1136,6 +1148,12 @@ public static function table(Table $table): Table
->requireCapability(Capabilities::TENANT_DELETE) ->requireCapability(Capabilities::TENANT_DELETE)
->apply(), ->apply(),
]), ]),
])
->emptyStateHeading('No restore runs')
->emptyStateDescription('Start a restoration from a backup set.')
->emptyStateIcon('heroicon-o-arrow-path-rounded-square')
->emptyStateActions([
static::makeCreateAction(),
]); ]);
} }

View File

@ -3,9 +3,6 @@
namespace App\Filament\Resources\RestoreRunResource\Pages; namespace App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListRestoreRuns extends ListRecords class ListRestoreRuns extends ListRecords
@ -19,25 +16,9 @@ private function tableHasRecords(): bool
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return [ return [
$create->visible(fn (): bool => $this->tableHasRecords()), RestoreRunResource::makeCreateAction()
]; ->visible(fn (): bool => $this->tableHasRecords()),
}
protected function getTableEmptyStateActions(): array
{
$create = Actions\CreateAction::make();
UiEnforcement::forAction($create)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return [
$create,
]; ];
} }
} }

View File

@ -3,7 +3,6 @@
namespace App\Filament\Resources\Workspaces\Pages; namespace App\Filament\Resources\Workspaces\Pages;
use App\Filament\Resources\Workspaces\WorkspaceResource; use App\Filament\Resources\Workspaces\WorkspaceResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
class ListWorkspaces extends ListRecords class ListWorkspaces extends ListRecords
@ -12,37 +11,14 @@ class ListWorkspaces extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [$this->makeHeaderCreateAction()]; return [
} WorkspaceResource::makeCreateAction()
->visible(fn (): bool => $this->tableHasRecords()),
protected function getTableEmptyStateActions(): array ];
{
return [$this->makeEmptyStateCreateAction()];
} }
private function tableHasRecords(): bool private function tableHasRecords(): bool
{ {
return $this->getTableRecords()->count() > 0; 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
{
return Actions\CreateAction::make()
->label('New workspace')
->disabled(fn (): bool => ! WorkspaceResource::canCreate())
->tooltip(fn (): ?string => WorkspaceResource::canCreate()
? null
: 'You do not have permission to create workspaces.');
}
} }

View File

@ -136,6 +136,16 @@ public static function form(Schema $schema): Schema
]); ]);
} }
public static function makeCreateAction(): Actions\CreateAction
{
return Actions\CreateAction::make()
->label('New workspace')
->disabled(fn (): bool => ! static::canCreate())
->tooltip(fn (): ?string => static::canCreate()
? null
: 'You do not have permission to create workspaces.');
}
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return $table return $table
@ -155,6 +165,12 @@ public static function table(Table $table): Table
) )
->requireCapability(Capabilities::WORKSPACE_MANAGE) ->requireCapability(Capabilities::WORKSPACE_MANAGE)
->apply(), ->apply(),
])
->emptyStateHeading('No workspaces')
->emptyStateDescription('Create your first workspace.')
->emptyStateIcon('heroicon-o-squares-2x2')
->emptyStateActions([
static::makeCreateAction(),
]); ]);
} }

View File

@ -0,0 +1,91 @@
# Discoveries
> Things found during implementation that don't belong in the current spec.
> Review weekly. Promote to [spec-candidates.md](spec-candidates.md) or discard.
**Last reviewed**: 2026-03-08
---
## 2026-03-08 — Alert `sla_due` event type is dead code
- **Source**: Spec 109 (Review Pack Export)
- **Observation**: `sla_due` alert rule event type exists in the schema but no producer dispatches it. Dead code path.
- **Category**: cleanup
- **Priority**: low
- **Suggested follow-up**: Remove or implement. If SLA alerting is a future feature, document the intent; otherwise delete.
---
## 2026-03-08 — Alert Deliveries header-action exemption needs permanent documentation
- **Source**: Spec 122 (Empty State Consistency)
- **Observation**: Alert Deliveries is the first resource with an explicit UX-001 relocation exemption — its CTA exists only in the empty state and does NOT relocate to the header. This needs to remain documented so future developers don't "fix" it.
- **Category**: documentation
- **Priority**: low
- **Suggested follow-up**: Ensure the exemption is captured in the Action Surface Contract guard tests and/or resource-level comments.
---
## 2026-03-08 — Historical findings backfill for `source` field
- **Source**: Spec 101 (Golden Master Baseline Governance)
- **Observation**: The `source` field on findings was added but historical findings may not be backfilled. Reporting accuracy depends on this.
- **Category**: data integrity
- **Priority**: medium
- **Suggested follow-up**: One-time migration or backfill job to classify existing findings by source.
---
## 2026-03-08 — Baseline profile hard-delete deferred
- **Source**: Spec 101 (Golden Master Baseline Governance)
- **Observation**: Baseline profiles can only be archived, not hard-deleted, in v1. If archive accumulation becomes a problem, a hard-delete with cascade needs to be built.
- **Category**: feature gap
- **Priority**: low
- **Suggested follow-up**: Monitor archive count. Spec only if it becomes a user-reported issue.
---
## 2026-03-08 — Drift engine hard-fail when no Inventory Sync exists
- **Source**: Spec 119 (Baseline Drift Engine Cutover)
- **Observation**: Currently drift capture does NOT hard-fail when no completed Inventory Sync exists. This was deferred as a "larger product behavior change."
- **Category**: hardening
- **Priority**: medium
- **Suggested follow-up**: Evaluate whether capturing drift without a baseline sync produces misleading results. If so, enforce the prerequisite.
---
## 2026-03-08 — Inventory landing page may be redundant
- **Source**: Product review, dashboard analysis
- **Observation**: The Inventory nav section has a landing "Home" page that may not add value beyond what the Policies and Policy Versions pages provide directly.
- **Category**: UX polish
- **Priority**: low
- **Suggested follow-up**: Consider making Inventory a pure navigation group (no landing page) in a future IA cleanup.
---
## 2026-03-08 — Dashboard lacks enterprise-grade visual hierarchy
- **Source**: Product review 2026-03-08
- **Observation**: Stat widgets show raw numbers without trends. "Needs Attention" zone is visually equal to other content. Baseline Governance card is small and easy to miss. Operations table lacks duration/count columns.
- **Category**: UX polish
- **Priority**: medium
- **Suggested follow-up**: Promoted to spec-candidates.md as "Dashboard Polish (Enterprise-grade)".
---
## 2026-03-08 — Performance indexes for system console windowed queries
- **Source**: Spec 114 (System Console Control Tower)
- **Observation**: EXPLAIN baselines don't show pressure yet, but windowed queries on operation_runs could become slow at scale. Indexes were explicitly deferred.
- **Category**: performance
- **Priority**: low
- **Suggested follow-up**: Monitor query times. Add indexes proactively if run count exceeds ~100k.
---
## Template
```md
## YYYY-MM-DD — Short title
- **Source**: Spec NNN (Name) | chat | audit | coding
- **Observation**:
- **Category**: feature gap | cleanup | hardening | UX polish | performance | documentation | data integrity
- **Priority**: low | medium | high
- **Suggested follow-up**:
```

121
docs/product/principles.md Normal file
View File

@ -0,0 +1,121 @@
# Product Principles
> Permanent product principles that govern every spec, every UI decision, and every architectural choice.
> New specs must align with these. If a principle needs to change, update this file first.
**Last reviewed**: 2026-03-08
---
## Identity & Isolation
### Workspace-first context
Workspace is the primary session context. Every UI surface, every query, every action is workspace-scoped.
Non-members receive deny-as-not-found (404 semantics) — they never learn the resource exists.
### Tenant isolation (non-negotiable)
Every read/write is tenant-scoped. Cross-tenant views are explicit, access-checked, aggregation-based.
Non-member → 404. No cross-embedding of workspace-owned and tenant-owned data.
### SCOPE-001: Strict ownership model
- **Workspace-owned** = standards, templates, configuration, baselines
- **Tenant-owned** = observed state, evidence, artifacts, inventory
---
## Authorization & Safety
### Capability-first RBAC
Single canonical registry (`Capabilities.php`). No raw strings. CI fails on unknown capabilities.
UI visibility is never a security boundary — missing server-side auth is a P0 bug.
### Visible-but-disabled UX
Members see disabled actions with tooltip explaining the missing capability.
Non-members see nothing (404 semantics).
### Destructive actions require safe flows
All destructive actions → `requiresConfirmation()`. No exceptions.
Write operations require: preview/dry-run → confirmation → audit log → tests.
High-risk types default to `preview-only`.
---
## Operations & Observability
### 3-Surface Feedback (non-negotiable)
1. **Toast** — intent acknowledged
2. **Progress** — active work visible
3. **Terminal DB Notification** — audit record
No other feedback patterns. No silent mutations.
### OperationRun lifecycle is service-owned
All status/outcome transitions via `OperationRunService` only.
Summary counts via `OperationSummaryKeys::all()`. Flat numeric only.
### Enterprise-grade auditability
Every mutation has a trail. Backup created, restore attempted, policy change detected — logged, tenant-scoped, RBAC-respecting.
---
## Data & Architecture
### Inventory-first, Snapshots-second
- `InventoryItem` = last observed metadata
- `PolicyVersion.snapshot` = explicit immutable JSONB capture
- Intune remains external source of truth
### Single Contract Path to Graph
All MS Graph calls via `GraphClientInterface`. Endpoints modeled in `config/graph_contracts.php`.
No hardcoded "quick endpoints". Unknown types fail safe.
### Deterministic Capabilities
Backup/restore/risk flags derived deterministically from config via Capabilities Resolver.
Must be snapshot-testable.
### Data minimization & safe logging
Inventory = metadata only. No secrets in logs.
Monitoring relies on run records + error codes.
---
## UI & Information Architecture
### UX-001: Layout & IA Standards
Main/Aside layout. Sections required. View pages use Infolists.
Empty states with specific title + explanation + exactly 1 CTA.
### Action Surface Contract (non-negotiable)
Required surfaces per page type (list/view/create/edit).
Max 2 visible row actions. Destructive requires confirmation.
Every spec with UI changes must include a UI Action Matrix.
### Badge semantics centralized
All status badges via `BadgeCatalog` / `BadgeRenderer`. No ad-hoc badge mappings.
### Canonical navigation and terminology
Consistent naming, consistent routing, consistent mental model.
No competing terms for the same concept.
---
## Process
### Spec-first workflow
Runtime behavior changes require spec update first.
Every spec must declare: scope, primary routes, data ownership, RBAC requirements (SCOPE-002).
### Regression guards mandatory
RBAC regression tests per role. Ops-UX regression guards prevent direct status writes and ad-hoc notifications.
Architectural guard tests enforce code-level contracts.
---
## Filament v5 Alignment
### Non-negotiables
- Livewire v4.0+
- Panel providers in `bootstrap/providers.php`
- Global search requires Edit/View page or is disabled
- Prefer render hooks + CSS hooks over publishing internal views
- Heavy assets loaded on-demand (`loadedOnRequest()`)

129
docs/product/roadmap.md Normal file
View File

@ -0,0 +1,129 @@
# Product Roadmap
> Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs.
**Last updated**: 2026-03-08
---
## Release History
| Release | Theme | Status |
|---------|-------|--------|
| **R1 "Golden Master Governance"** | Baseline drift as production feature, operations polish | **Done** |
| **R1 cont.** | Ops canonicalization, action surface contract, ops-ux enforcement | **Done** |
| **R2 "Tenant Reviews & Evidence"** | Evidence packs, stored reports, permission posture, alerts | **Partial** |
| **R2 cont.** | Alert escalation + notification routing | **Done** |
---
## Active / Near-term
### UI & Product Maturity Polish
Empty state consistency, list-expand parity, workspace chooser refinement, navigation semantics.
Goal: Every surface feels intentional and guided for first-run evaluation.
**Active specs**: 122, 121, 112
### Secret & Security Hardening
Secret redaction integrity, provider access hardening, required permissions sidebar.
Goal: Enterprise trust — no credential leaks, no permission gaps.
**Active specs**: 120, 108, 106
### Baseline Drift Engine (Cutover)
Full content capture, cutover to unified engine, resume capability.
Goal: Ship drift detection as the complete production governance feature.
**Active specs**: 119 (cutover)
---
## Planned (Next Quarter)
### R2 Completion — Evidence & Exception Workflows
- Review pack export (Spec 109 — done)
- Exception/risk-acceptance workflow for Findings → **Not yet specced**
- Formal "evidence pack" entity → **Not yet specced**
- Workspace-level PII override for review packs → deferred from 109
### Policy Lifecycle / Ghost Policies
Soft delete detection, automatic restore, "Deleted" badge, restore from backup.
Draft exists (Spec 900). Needs spec refresh and prioritization.
**Risk**: Ghost policies create confusion for backup item references.
### Platform Operations Maturity
- CSV export for filtered run metadata (deferred from Spec 114)
- Raw error/context drilldowns for system console (deferred from Spec 114)
- Multi-workspace operator selection in `/system` (deferred from Spec 113)
---
## Mid-term (23 Quarters)
### MSP Portfolio & Operations (Multi-Tenant)
Multi-tenant health dashboard, SLA/compliance reports (PDF), cross-tenant troubleshooting center.
**Source**: 0800-future-features brainstorming, identified as highest priority pillar.
**Prerequisite**: Cross-tenant compare (Spec 043 — draft only).
### Drift & Change Governance ("Revenue Lever #1")
Change approval workflows (DEV→PROD with audit pack), guardrails/policy freeze windows, tamper detection.
**Source**: 0800-future-features brainstorming.
**Prerequisite**: Drift engine fully shipped, findings workflow mature.
### Standardization & Policy Quality ("Intune Linting")
Policy linter (naming, scope tag requirements, no All-Users on high-risk), company standards as templates, policy hygiene (duplicate finder, unassigned, orphaned, stale).
**Source**: 0800-future-features brainstorming.
---
## Long-term
### Tenant-to-Tenant / Staging→Prod Promotion
Compare/diff between tenants, mapping UI (groups, scope tags, filters, named locations, app refs), promotion plan (preview → dry-run → cutover → verify).
**Source**: 0800-future-features, Spec 043 draft.
### Recovery Confidence ("Killer Feature")
Automated restore tests in test tenants, recovery readiness report, preflight score.
**Source**: 0800-future-features brainstorming.
### Security Suite Layer
Security posture score, blast radius display, opt-in high-risk enablement.
**Source**: 0800-future-features brainstorming.
### Script & Secrets Governance
Script diff + approval + rollback, secret scanning, allowlist/signing workflow.
**Source**: 0800-future-features brainstorming.
---
## Infrastructure & Platform Debt
| Item | Risk | Status |
|------|------|--------|
| No `.env.example` in repo | Onboarding friction | Open |
| No CI pipeline config | No automated quality gate | Open |
| No PHPStan/Larastan | No static analysis | Open |
| SQLite for tests vs PostgreSQL in prod | Schema drift risk | Open |
| No formal release process | Manual deploys | Open |
| Dokploy config external to repo | Env drift | Open |
---
## Priority Ranking (from Product Brainstorming)
1. MSP Portfolio + Alerting
2. Drift + Approval Workflows
3. Standardization / Linting
4. Promotion DEV→PROD
5. Recovery Confidence
---
## How to use this file
- **Big themes** live here.
- **Concrete spec candidates** → see [spec-candidates.md](spec-candidates.md)
- **Small discoveries from implementation** → see [discoveries.md](discoveries.md)
- **Product principles** → see [principles.md](principles.md)

View File

@ -0,0 +1,125 @@
# Spec Candidates
> Concrete future specs waiting for prioritization.
> Each entry has enough structure to become a real spec when the time comes.
>
> **Flow**: Inbox → Qualified → Planned → Spec created → removed from this file
**Last reviewed**: 2026-03-08
---
## Inbox
> Ungefiltert. Kurze Notiz reicht. Wöchentlich sichten.
- Dashboard trend visualizations (sparklines, compliance gauge, drift-over-time chart)
- Dashboard "Needs Attention" should be visually louder (alert color, icon, severity weighting)
- Operations table should show duration + affected policy count
- First-run onboarding wizard or checklist for new tenants
- Density control / comfortable view toggle for admin tables
- Inventory landing page may be redundant — consider pure navigation section
- Monitoring hub calmer polling / less UI noise
- Settings change history → explainable change tracking
- Workspace chooser v2: search, sort, favorites, pins, environment badges, last activity
---
## Qualified
> Problem + Nutzen klar. Scope noch offen. Braucht noch Priorisierung.
### Exception / Risk-Acceptance Workflow for Findings
- **Type**: feature
- **Source**: HANDOVER gap analysis, Spec 111 follow-up
- **Problem**: Finding has `risk_accepted` status but no formal exception entity. No workflow to accept risk, track justification, or expire acceptance.
- **Why it matters**: Enterprise compliance requires documented risk acceptance. Auditors ask "who accepted this and when?"
- **Proposed direction**: Exception entity linked to Finding, approval flow, expiry tracking, audit trail
- **Dependencies**: Findings workflow (Spec 111) complete
- **Priority**: high
### Evidence Pack Entity
- **Type**: feature
- **Source**: HANDOVER gap, R2 theme completion
- **Problem**: Review pack export (Spec 109) exists, permission posture (104/105) exists, but no formal "evidence pack" that bundles these for external audit/compliance submission.
- **Why it matters**: Enterprise customers need a single deliverable for auditors — not separate exports.
- **Proposed direction**: Evidence pack = curated bundle of review pack + posture report + findings summary + baseline governance state
- **Dependencies**: Review pack export (109), permission posture (104)
- **Priority**: high
### Policy Lifecycle / Ghost Policies (Spec 900 refresh)
- **Type**: feature
- **Source**: Spec 900 draft (2025-12-22), HANDOVER risk #9
- **Problem**: Policies deleted in Intune remain in TenantAtlas indefinitely. No deletion indicators. Backup items reference "ghost" policies.
- **Why it matters**: Data integrity, user confusion, backup reliability
- **Proposed direction**: Soft delete detection during sync, auto-restore on reappear, "Deleted" badge, restore from backup. Draft in Spec 900.
- **Dependencies**: Inventory sync stable
- **Priority**: medium
### Schema-driven Secret Classification
- **Type**: hardening
- **Source**: Spec 120 deferred follow-up
- **Problem**: Secret redaction currently uses pattern-based detection. A schema-driven approach via `GraphContractRegistry` metadata would be more reliable.
- **Why it matters**: Reduces false negatives in secret redaction
- **Proposed direction**: Central classifier in `GraphContractRegistry`, regression corpus
- **Dependencies**: Secret redaction (120) stable, registry completeness (095)
- **Priority**: medium
### Cross-Tenant Compare & Promotion
- **Type**: feature
- **Source**: Spec 043 draft, 0800-future-features
- **Problem**: No way to compare policies between tenants or promote configurations from staging to production.
- **Why it matters**: Core MSP/enterprise workflow. Identified as top revenue lever in brainstorming.
- **Proposed direction**: Compare/diff UI, group/scope-tag mapping, promotion plan (preview → dry-run → cutover → verify)
- **Dependencies**: Inventory sync, backup/restore mature
- **Priority**: medium (high value, high effort)
### System Console Multi-Workspace Operator
- **Type**: feature
- **Source**: Spec 113 deferred
- **Problem**: System console (`/system`) currently can't select/filter across workspaces for platform operators.
- **Why it matters**: Platform ops need cross-workspace visibility for troubleshooting and monitoring.
- **Proposed direction**: New UX + entitlement model for system-level operators
- **Dependencies**: System console (114) stable
- **Priority**: low
### Workspace Chooser v2
- **Type**: polish
- **Source**: Spec 107 deferred backlog
- **Problem**: Current chooser is functional but basic. Missing search, sort, favorites, environment badges, last activity display.
- **Why it matters**: MSPs with 10+ workspaces need fast navigation.
- **Proposed direction**: Search + sort + pins, environment badge (Prod/Test/Staging), last activity per workspace, dropdown switcher in header
- **Dependencies**: Workspace chooser v1 (107) stable
- **Priority**: low
### Dashboard Polish (Enterprise-grade)
- **Type**: polish
- **Source**: Product review 2026-03-08
- **Problem**: Current dashboard shows raw numbers without context. No trend indicators, no severity weighting, governance card too small.
- **Why it matters**: First impression for evaluators. Enterprise admins compare with Datadog/Vanta/Drata/Intune Portal.
- **Proposed direction**: Trend sparklines, compliance gauge, severity-weighted drift table, actionable alert buttons, progressive disclosure
- **Dependencies**: Baseline governance (101), alerts (099), drift engine (119) stable
- **Priority**: medium
---
## Planned
> Ready for spec creation. Waiting for slot in active work.
*(empty — move items here when prioritized for next sprint)*
---
## Template
```md
### Title
- **Type**: feature | polish | hardening | bug | research
- **Source**: chat | audit | coding discovery | customer feedback | spec N follow-up
- **Problem**:
- **Why it matters**:
- **Proposed direction**:
- **Dependencies**:
- **Priority**: low | medium | high
```

View File

@ -0,0 +1,35 @@
# Specification Quality Checklist: Empty State Consistency Pass
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-08
**Feature**: [specs/122-empty-state-consistency/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This spec includes the required UI Action Matrix per the project constitution, but avoids code-level steps and avoids prescribing specific implementation mechanisms beyond the user-visible UX contract.
- Ready for `/speckit.plan`.

View File

@ -0,0 +1,286 @@
openapi: 3.1.0
info:
title: TenantPilot Internal Empty State Contracts
version: 1.0.0
description: |
Internal review contract for Spec 122.
These are Filament list surfaces, not public API endpoints. The document exists so the
feature has an explicit, reviewable contract under specs/.../contracts/.
paths:
/admin/t/{tenant}/policies:
get:
summary: Tenant policies list empty state
description: Renders the policies list. When no rows exist, the page must expose the EmptyStateContract for the policies surface.
parameters:
- $ref: '#/components/parameters/TenantPathParam'
responses:
'200':
description: Policies list rendered
content:
application/json:
schema:
$ref: '#/components/schemas/PolicyEmptyStateSurface'
/admin/t/{tenant}/backup-sets:
get:
summary: Tenant backup sets list empty state
parameters:
- $ref: '#/components/parameters/TenantPathParam'
responses:
'200':
description: Backup sets list rendered
content:
application/json:
schema:
$ref: '#/components/schemas/BackupSetEmptyStateSurface'
/admin/t/{tenant}/restore-runs:
get:
summary: Tenant restore runs list empty state
parameters:
- $ref: '#/components/parameters/TenantPathParam'
responses:
'200':
description: Restore runs list rendered
content:
application/json:
schema:
$ref: '#/components/schemas/RestoreRunEmptyStateSurface'
/admin/t/{tenant}/backup-schedules:
get:
summary: Tenant backup schedules list empty state
parameters:
- $ref: '#/components/parameters/TenantPathParam'
responses:
'200':
description: Backup schedules list rendered
content:
application/json:
schema:
$ref: '#/components/schemas/BackupScheduleEmptyStateSurface'
/admin/workspaces:
get:
summary: Workspace management list empty state
responses:
'200':
description: Workspaces list rendered
content:
application/json:
schema:
$ref: '#/components/schemas/WorkspaceEmptyStateSurface'
/admin/alert-deliveries:
get:
summary: Alert deliveries list empty state
responses:
'200':
description: Alert deliveries list rendered
content:
application/json:
schema:
$ref: '#/components/schemas/AlertDeliveryEmptyStateSurface'
components:
parameters:
TenantPathParam:
name: tenant
in: path
required: true
schema:
type: string
description: Current tenant route identifier for tenant-scoped Filament resources.
schemas:
EmptyStateContract:
type: object
additionalProperties: false
required:
- surface
- ownership_scope
- heading
- description
- icon
- primary_cta
- cta_count
properties:
surface:
type: string
ownership_scope:
type: string
enum: [tenant, workspace, workspace_context_monitoring]
heading:
type: string
description:
type: string
icon:
type: string
description: Heroicon identifier.
cta_count:
type: integer
const: 1
primary_cta:
$ref: '#/components/schemas/PrimaryCtaContract'
definition_source:
type: string
enum: [resource_table, page_helper_fallback]
preserved_capability_behavior:
type: string
enum: [disabled_with_explanation, hidden, mixed_by_existing_resource_rules]
PrimaryCtaContract:
type: object
additionalProperties: false
required:
- label
- intent
- target_surface
properties:
label:
type: string
intent:
type: string
enum: [create, navigate, queue_operation]
target_surface:
type: string
capability_aware:
type: boolean
notes:
type: string
nullable: true
PolicyEmptyStateSurface:
allOf:
- $ref: '#/components/schemas/EmptyStateContract'
- type: object
properties:
surface:
const: policy_list
ownership_scope:
const: tenant
heading:
const: No policies synced yet
description:
const: Sync your first tenant to see Intune policies here.
primary_cta:
allOf:
- $ref: '#/components/schemas/PrimaryCtaContract'
- type: object
properties:
label:
const: Sync from Intune
intent:
const: queue_operation
BackupSetEmptyStateSurface:
allOf:
- $ref: '#/components/schemas/EmptyStateContract'
- type: object
properties:
surface:
const: backup_set_list
ownership_scope:
const: tenant
heading:
const: No backup sets
description:
const: Create a backup set to start protecting your configurations.
primary_cta:
allOf:
- $ref: '#/components/schemas/PrimaryCtaContract'
- type: object
properties:
label:
const: Create backup set
intent:
const: create
RestoreRunEmptyStateSurface:
allOf:
- $ref: '#/components/schemas/EmptyStateContract'
- type: object
properties:
surface:
const: restore_run_list
ownership_scope:
const: tenant
heading:
const: No restore runs
description:
const: Start a restoration from a backup set.
primary_cta:
allOf:
- $ref: '#/components/schemas/PrimaryCtaContract'
- type: object
properties:
label:
const: New restore run
intent:
const: create
BackupScheduleEmptyStateSurface:
allOf:
- $ref: '#/components/schemas/EmptyStateContract'
- type: object
properties:
surface:
const: backup_schedule_list
ownership_scope:
const: tenant
heading:
const: No schedules configured
description:
const: Set up automated backups.
primary_cta:
allOf:
- $ref: '#/components/schemas/PrimaryCtaContract'
- type: object
properties:
label:
const: New backup schedule
intent:
const: create
WorkspaceEmptyStateSurface:
allOf:
- $ref: '#/components/schemas/EmptyStateContract'
- type: object
properties:
surface:
const: workspace_list
ownership_scope:
const: workspace
heading:
const: No workspaces
description:
const: Create your first workspace.
primary_cta:
allOf:
- $ref: '#/components/schemas/PrimaryCtaContract'
- type: object
properties:
label:
const: New workspace
intent:
const: create
AlertDeliveryEmptyStateSurface:
allOf:
- $ref: '#/components/schemas/EmptyStateContract'
- type: object
properties:
surface:
const: alert_delivery_list
ownership_scope:
const: workspace_context_monitoring
heading:
const: No alert deliveries
description:
const: Deliveries appear automatically when alert rules fire.
primary_cta:
allOf:
- $ref: '#/components/schemas/PrimaryCtaContract'
- type: object
properties:
label:
const: View alert rules
intent:
const: navigate

View File

@ -0,0 +1,142 @@
# Data Model: Empty State Consistency Pass
**Feature**: 122-empty-state-consistency | **Date**: 2026-03-08
## Overview
This feature does not add or modify database tables. It standardizes the UI contract for six existing Filament list surfaces when their datasets are empty.
The feature operates on existing resource/page configuration and existing authorization decisions.
## Existing UI Contract Concepts
### EmptyStateSurface
| Attribute | Type | Notes |
|-----------|------|-------|
| `resource` | string | Filament resource owning the list surface |
| `ownership_scope` | enum | `tenant`, `workspace`, or `workspace-context monitoring` |
| `heading` | string | Specific, contextual title for the empty state |
| `description` | string | Short explanation of why the list is empty and what to do next |
| `icon` | string | Heroicon name matching the module semantics |
| `primary_cta_label` | string | Exactly one user-facing CTA label |
| `primary_cta_type` | enum | `create`, `navigate`, or `queue-operation` |
| `capability_behavior` | enum | `disabled-with-explanation` or `hidden`, preserved per existing resource behavior |
| `definition_source` | enum | Prefer `resource.table`; fallback `list-page helper` only when required |
### EmptyStatePrimaryAction
| Attribute | Type | Notes |
|-----------|------|-------|
| `label` | string | Single guided next-step action |
| `target` | string | Destination page or mounted Filament action |
| `enforcement_helper` | string | Existing `UiEnforcement` / `WorkspaceUiEnforcement` pattern when applicable |
| `capability` | string/null | Existing canonical capability required to execute the action |
| `header_relocation_rule` | string | Same CTA appears in the table header when records exist, unless the surface has an explicit documented exemption |
### VisualQaEvidence
| Attribute | Type | Notes |
|-----------|------|-------|
| `surface` | string | One of the six in-scope list pages |
| `mode` | enum | `light` or `dark` |
| `artifact_location` | string | PR/review attachment reference; not committed repo data |
| `review_result` | enum | `pass` or `follow-up` |
## In-Scope Surface Instances
### Policy list
| Field | Value |
|------|-------|
| Scope | Tenant-owned |
| Empty reason | Policies have not been synced for the tenant yet |
| Primary CTA type | Queue operation |
| Primary CTA label | `Sync from Intune` |
| Existing capability behavior | Capability-aware action using existing tenant sync enforcement |
### Backup Sets list
| Field | Value |
|------|-------|
| Scope | Tenant-owned |
| Empty reason | No backup sets have been created yet |
| Primary CTA type | Create |
| Primary CTA label | `Create backup set` |
| Existing capability behavior | Preserved from current create action behavior |
### Restore Runs list
| Field | Value |
|------|-------|
| Scope | Tenant-owned |
| Empty reason | No restore workflows have been initiated yet |
| Primary CTA type | Create |
| Primary CTA label | `New restore run` |
| Existing capability behavior | Preserved from current create action behavior |
### Backup Schedules list
| Field | Value |
|------|-------|
| Scope | Tenant-owned |
| Empty reason | No automated backup schedules are configured |
| Primary CTA type | Create |
| Primary CTA label | `New backup schedule` |
| Existing capability behavior | Disabled-with-tooltip for valid members without schedule-manage capability |
### Workspaces list
| Field | Value |
|------|-------|
| Scope | Workspace-owned |
| Empty reason | No active workspaces are available to the member |
| Primary CTA type | Create |
| Primary CTA label | `New workspace` |
| Existing capability behavior | Preserved from current workspace create action behavior |
### Alert Deliveries list
| Field | Value |
|------|-------|
| Scope | Workspace-context monitoring |
| Empty reason | No alert deliveries have occurred yet |
| Primary CTA type | Navigate |
| Primary CTA label | `View alert rules` |
| Existing capability behavior | Read-only list; CTA navigates to the configuration surface most likely to explain the empty history |
| Header relocation rule | Explicit exemption: CTA appears only while the list is empty; non-empty state remains header-action free |
## Relationships
```text
EmptyStateSurface
-> has one EmptyStatePrimaryAction
-> belongs to one existing Filament resource list page
-> is validated by programmatic tests
-> is visually confirmed by PR/review evidence in light and dark mode
```
## State Transition
```text
[0 records on list page]
-> render complete empty-state contract
(icon + heading + description + exactly 1 CTA)
-> user follows CTA or waits for data-producing event
[records exist]
-> empty state disappears
-> primary CTA relocates to the table header when applicable
-> Alert Deliveries remains header-action free by explicit exemption
-> populated table behavior remains unchanged
```
## Validation Rules
| Rule | Result |
|------|--------|
| Exactly one empty-state CTA per in-scope list | Required |
| Empty-state CTA must preserve existing authorization behavior | Required |
| Non-members continue to receive 404 semantics | Required |
| Members missing capability continue to get 403 on execution | Required |
| Alert Deliveries CTA is navigational, not mutating | Required |
| No schema, route, or Graph contract changes | Required |

View File

@ -0,0 +1,174 @@
# Implementation Plan: Empty State Consistency Pass
**Branch**: `122-empty-state-consistency` | **Date**: 2026-03-08 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/122-empty-state-consistency/spec.md`
**Note**: This plan is generated by the `/speckit.plan` workflow and aligned to the current repository constitution.
## Summary
Unify the empty-state experience across six primary Filament list pages so each empty list renders an enterprise-grade icon, heading, description, and exactly one guided CTA. The implementation will prefer resource-level `table()` empty-state definitions, preserve existing capability-aware CTA behavior, reuse local patterns from `BaselineProfileResource` and `ReviewPackResource`, update `AlertDeliveryResource`s action-surface declaration to remove the empty-state exemption, document Alert Deliveries as the sole explicit UX-001 header-relocation exemption, and extend focused Pest + Livewire coverage for content, CTA outcomes, populated-state regression behavior, RBAC behavior, and action-surface guard compliance.
## Technical Context
**Language/Version**: PHP 8.4.15 / Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4
**Storage**: PostgreSQL + existing workspace/tenant session context; no schema changes
**Testing**: Pest v4 feature and Livewire component tests, plus manual light/dark visual QA evidence attached to PR/review
**Target Platform**: Laravel Sail web admin + tenant panels (`/admin`, `/admin/t/{tenant}/...`) in containerized deployment
**Project Type**: Laravel monolith / Filament web application
**Performance Goals**: No additional remote calls, no new queued work, and no material increase in list-render query cost; empty-state rendering remains a table-configuration concern only
**Constraints**: Preserve existing RBAC/UI enforcement, keep populated-table behavior unchanged, use exactly one empty-state CTA per surface, prefer resource `table()` definitions, keep screenshots out of the repo, and treat Alert Deliveries as the only explicit exemption from header CTA relocation once rows exist
**Scale/Scope**: 6 list surfaces, 6 resource/page empty-state retrofits, 1 action-surface declaration update for Alert Deliveries, and focused regression tests; no migrations, no new services, no new routes
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first: PASS (feature only affects empty-state presentation on existing inventory/backup/monitoring/workspace lists; no inventory-vs-snapshot semantics change).
- Read/write separation: PASS (no new writes or operational flows are introduced; existing mutating/create/start actions keep their current behavior).
- Graph contract path: PASS (no Microsoft Graph endpoints, contracts, or client usage change).
- Deterministic capabilities: PASS (no new capabilities; existing canonical capability checks and enforcement helpers remain in place).
- RBAC-UX: PASS (non-member 404 and member-without-capability 403 semantics remain unchanged; the feature only changes empty-state presentation).
- Workspace isolation: PASS (workspace-scoped resources remain workspace-safe; no chooser or context rules are altered).
- Destructive confirmation: PASS / N/A (no destructive actions are added or modified).
- Global search: PASS / N/A (no global search behavior changes).
- Tenant isolation: PASS (tenant-owned lists stay tenant-scoped; Alert Deliveries remains entitlement-filtered in workspace context).
- Run observability: PASS / N/A (feature introduces no new long-running, remote, or queued work; the existing policy sync CTA keeps its current operation-run behavior unchanged).
- Ops-UX 3-surface feedback: PASS / N/A for new work; preserved for existing queue-operation CTA on policies.
- Ops-UX lifecycle: PASS / N/A (no new `OperationRun` lifecycle changes).
- Ops-UX summary counts: PASS / N/A (no new `OperationRun` metrics producers).
- Ops-UX guards: PASS (existing guards remain relevant; plan includes updating action-surface guard coverage where the declared contract changes).
- Ops-UX system runs: PASS / N/A (no system-run behavior changes).
- Automation: PASS / N/A (no scheduled or queued logic added).
- Data minimization: PASS (no new persisted state or logging payloads).
- Badge semantics (BADGE-001): PASS / N/A (no new status-like badges are added; empty-state icons are not governed by BADGE-001).
- Filament UI Action Surface Contract: PASS with required implementation update. The six in-scope list surfaces already have action surfaces; the plan must update empty-state declarations/content and remove the current `AlertDeliveryResource` empty-state exemption.
- Filament UI UX-001: PASS. The feature directly advances UX-001 by ensuring each in-scope list empty state has a specific title, explanation, and one CTA.
## Project Structure
### Documentation (this feature)
```text
specs/122-empty-state-consistency/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── empty-states.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ └── Resources/
│ ├── PolicyResource.php # MODIFY — move complete empty state into table() and keep sync CTA semantics
│ ├── BackupSetResource.php # MODIFY — define complete empty state in table()
│ ├── RestoreRunResource.php # MODIFY — define complete empty state in table()
│ ├── BackupScheduleResource.php # MODIFY — define complete empty state in table()
│ ├── AlertDeliveryResource.php # MODIFY — define complete empty state + update actionSurfaceDeclaration()
│ ├── PolicyResource/
│ │ └── Pages/
│ │ └── ListPolicies.php # MODIFY — reduce helper-only empty-state responsibility if needed
│ ├── BackupSetResource/
│ │ └── Pages/
│ │ └── ListBackupSets.php # MODIFY — align empty-state CTA ownership with resource table()
│ ├── RestoreRunResource/
│ │ └── Pages/
│ │ └── ListRestoreRuns.php # MODIFY — align empty-state CTA ownership with resource table()
│ ├── BackupScheduleResource/
│ │ └── Pages/
│ │ └── ListBackupSchedules.php # MODIFY — align empty-state CTA ownership with resource table()
│ └── Workspaces/
│ ├── WorkspaceResource.php # MODIFY — define complete empty state in table()
│ └── Pages/
│ └── ListWorkspaces.php # MODIFY — align empty-state CTA ownership with resource table()
tests/
├── Feature/
│ ├── Filament/
│ │ ├── CreateCtaPlacementTest.php # MODIFY — preserve empty-state/header CTA relocation behavior
│ │ ├── Alerts/
│ │ │ └── AlertDeliveryViewerTest.php # MODIFY — extend empty-state / authorization coverage for alert deliveries
│ │ └── [new or updated empty-state tests] # ADD/MODIFY — per-resource empty-state content coverage
│ ├── BackupScheduling/
│ │ └── BackupScheduleLifecycleAuthorizationTest.php # MODIFY or reference — disabled tooltip behavior remains intact
│ ├── PolicySyncStartSurfaceTest.php # MODIFY or reference — policy sync CTA capability behavior remains intact
│ └── Guards/
│ └── ActionSurfaceContractTest.php # MODIFY — cover updated AlertDelivery empty-state declaration
resources/
└── views/
└── [no new view templates expected] # prefer native Filament table empty-state configuration
```
**Structure Decision**: Keep the work inside the existing Laravel/Filament monolith. The feature is a resource-level UI consistency pass with focused list-page helper cleanup and targeted Pest/Livewire regression coverage; no new base folders, services, routes, or data layer objects are needed.
## Complexity Tracking
> No Constitution Check violations. No justifications needed.
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| — | — | — |
## Phase 0 — Research (output: `research.md`)
See: [research.md](./research.md)
Research goals:
- Confirm the preferred source of truth for complete Filament empty-state definitions in this repo.
- Confirm how to preserve existing capability-aware CTA behavior without creating RBAC regressions.
- Confirm the correct next-step CTA for the read-only Alert Deliveries surface.
- Confirm the smallest reliable test strategy using existing Pest + Livewire patterns and existing action-surface guards.
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
See:
- [data-model.md](./data-model.md)
- [contracts/empty-states.openapi.yaml](./contracts/empty-states.openapi.yaml)
- [quickstart.md](./quickstart.md)
Design focus:
- Model each list empty state as a UI contract over existing resource tables rather than a new domain or persistence object.
- Move in-scope surfaces toward resource-level `table()` ownership of empty-state heading, description, icon, and CTA.
- Preserve header-vs-empty-state CTA placement behavior already guarded by existing tests.
- Update `AlertDeliveryResource` from an empty-state exemption to an explicit guided empty-state declaration while preserving its documented no-header-action exemption once rows exist.
- Keep screenshots and dark-mode QA as review artifacts rather than committed documentation files.
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
### Resource empty-state retrofit
- Add or complete `emptyStateHeading()`, `emptyStateDescription()`, `emptyStateIcon()`, and `emptyStateActions()` on the six in-scope list surfaces.
- Reuse each surfaces existing primary CTA behavior (`create`, `navigate`, or `queue operation`) rather than inventing new workflows.
- Keep copy concise, enterprise-oriented, and module-specific.
### Ownership consolidation
- Move empty-state contract ownership into each resource `table()` configuration where Filament supports it.
- Reduce or remove list-page helper duplication so copy, icon, and CTA do not drift across files.
- Preserve existing empty/header CTA relocation behavior already covered by the repo.
### Contract and guard alignment
- Update `AlertDeliveryResource::actionSurfaceDeclaration()` to satisfy `ListEmptyState` rather than exempt it.
- Verify the changed surfaces still satisfy the Action Surface Contract and do not require new baseline exemptions.
### Regression protection
- Add or extend per-resource empty-state rendering assertions for heading, description, icon-related presence, and CTA labels.
- Add explicit CTA outcome coverage so empty-state actions are proven to navigate or queue the intended existing workflows.
- Add populated-state regression coverage for representative create-capable surfaces plus the Alert Deliveries header-action exemption.
- Extend capability tests so disabled or hidden CTA behavior remains aligned with the current resource-specific model.
- Keep response/feature coverage for workspace-context and alert-delivery entitlement behavior unchanged except for the new empty-state guidance.
### Verification
- Run focused Pest suites for CTA placement, authorization behavior, alert deliveries, and action-surface guards.
- Run Pint on dirty files through Sail.
- Capture light/dark visual QA evidence for all affected surfaces in PR/review artifacts.
## Constitution Check (Post-Design)
Re-check result: PASS. The design adds no new routes, data model changes, background work, or authorization semantics. It strengthens UX-001 compliance, preserves canonical RBAC/UI-enforcement behavior, and explicitly updates the Alert Deliveries action-surface declaration so the implemented UI stays aligned with the repositorys guard-enforced contract.

View File

@ -0,0 +1,67 @@
# Quickstart: Empty State Consistency Pass
**Feature**: 122-empty-state-consistency | **Date**: 2026-03-08
## Scope
Unify the empty-state experience across six primary Filament list pages:
- Policies
- Backup Sets
- Restore Runs
- Backup Schedules
- Workspaces
- Alert Deliveries
The pass adds complete empty-state content (heading, description, icon, CTA), keeps existing RBAC behavior, updates the affected UI action-surface contract where needed, and adds focused regression coverage.
## Implementation order
1. Update the in-scope resource `table()` definitions so each list owns its empty-state heading, description, icon, and CTA whenever Filament supports it.
2. Collapse or remove list-page-only empty-state helper responsibility where it would otherwise split the contract across resource and page classes.
3. Keep existing header CTA placement behavior intact so the CTA remains in the empty state only when there are no rows and relocates to the header when rows exist for create-capable surfaces.
4. For `AlertDeliveryResource`, add the guided empty state, update the action-surface declaration to satisfy `ListEmptyState` rather than exempting it, and preserve the documented no-header-action exemption once rows exist.
5. Add or extend per-resource tests for empty-state copy, CTA presence, CTA outcomes, and capability-aware visibility or disabled behavior.
6. Update shared guard and placement tests where the changed surfaces should now be covered explicitly, including representative populated-state regressions.
7. Perform manual light/dark visual verification and attach evidence to the PR or review.
8. Run focused Sail-based tests and Pint.
## Primary files to modify
- [app/Filament/Resources/PolicyResource.php](../../../app/Filament/Resources/PolicyResource.php)
- [app/Filament/Resources/PolicyResource/Pages/ListPolicies.php](../../../app/Filament/Resources/PolicyResource/Pages/ListPolicies.php)
- [app/Filament/Resources/BackupSetResource.php](../../../app/Filament/Resources/BackupSetResource.php)
- [app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php](../../../app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php)
- [app/Filament/Resources/RestoreRunResource.php](../../../app/Filament/Resources/RestoreRunResource.php)
- [app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php](../../../app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php)
- [app/Filament/Resources/BackupScheduleResource.php](../../../app/Filament/Resources/BackupScheduleResource.php)
- [app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php](../../../app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php)
- [app/Filament/Resources/Workspaces/WorkspaceResource.php](../../../app/Filament/Resources/Workspaces/WorkspaceResource.php)
- [app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php](../../../app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php)
- [app/Filament/Resources/AlertDeliveryResource.php](../../../app/Filament/Resources/AlertDeliveryResource.php)
## Likely test files
- [tests/Feature/Filament/CreateCtaPlacementTest.php](../../../tests/Feature/Filament/CreateCtaPlacementTest.php)
- [tests/Feature/Guards/ActionSurfaceContractTest.php](../../../tests/Feature/Guards/ActionSurfaceContractTest.php)
- [tests/Feature/PolicySyncStartSurfaceTest.php](../../../tests/Feature/PolicySyncStartSurfaceTest.php)
- [tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php](../../../tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php)
- [tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php](../../../tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php)
## Validation commands
```bash
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/Alerts/AlertDeliveryViewerTest.php
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
vendor/bin/sail bin pint --dirty --format agent
```
## Expected outcome
- Every in-scope list page shows a deliberate, contextual empty state.
- Each empty state has exactly one CTA and keeps existing authorization behavior.
- `AlertDeliveryResource` no longer relies on an empty-state exemption.
- Existing populated-table behavior and CTA-placement behavior remain intact, with Alert Deliveries preserved as the only documented no-header-action exemption.
- Light/dark visual QA evidence is attached to review artifacts, not committed to the repository.

View File

@ -0,0 +1,73 @@
# Research: Empty State Consistency Pass
**Feature**: 122-empty-state-consistency | **Date**: 2026-03-08
## R1: Source of truth for complete empty-state definitions
**Decision**: Prefer resource-level `table()` definitions for `emptyStateHeading()`, `emptyStateDescription()`, `emptyStateIcon()`, and `emptyStateActions()`, and use list-page helpers only where Filament or existing code structure makes that technically necessary.
**Rationale**: The repo already has strong resource-level empty-state examples in `BaselineProfileResource`, `ReviewPackResource`, and `AlertDestinationResource`. Resource-level definitions keep the empty-state contract close to the table configuration and reduce drift between copy, icon, and CTA placement. Existing list-page helpers can remain only as a compatibility layer when moving behavior fully into `table()` is impractical.
**Alternatives considered**:
- Keep all behavior in list-page helpers: rejected because it spreads the empty-state contract across resource and page classes.
- Split heading/description/icon in the resource and CTA in the page: rejected because the spec explicitly aims to avoid inconsistent ownership of the empty-state contract.
## R2: Canonical visual pattern reference in the current codebase
**Decision**: Use `BaselineProfileResource` as the tone and structural reference for concise enterprise empty-state copy, and use `ReviewPackResource` as the local example for an explicit `emptyStateIcon()` call.
**Rationale**: `BaselineProfileResource` already demonstrates the desired guidance-oriented structure: concise heading, short explanatory description, and one clear CTA. However, the current codebases clearest resource-level example that also includes `emptyStateIcon()` is `ReviewPackResource`. Combining those patterns matches the feature goal without inventing a new UI convention.
**Alternatives considered**:
- Treat `BaselineProfileResource` as a literal one-to-one implementation reference: rejected because it does not currently include an explicit icon call.
- Introduce a new shared helper abstraction first: rejected because the pass is intentionally low-risk and focused on consistency, not abstraction work.
## R3: Capability-aware CTA behavior must stay resource-specific
**Decision**: Preserve each resources current capability behavior for the primary CTA (disabled vs hidden), and require explanatory helper text or tooltips when the CTA is shown disabled.
**Rationale**: The repo already uses `UiEnforcement` and `WorkspaceUiEnforcement` patterns plus explicit disabled tooltip checks in Pest tests. Standardizing every resource to one visibility pattern would create avoidable RBAC regressions. The specs consistency target is visual and content consistency, not authorization behavior uniformity.
**Alternatives considered**:
- Force all CTAs to be visible-but-disabled: rejected because some resources intentionally hide actions for certain contexts.
- Force all CTAs to be hidden: rejected because it removes guidance for legitimate members who simply lack one capability.
## R4: Alert Deliveries requires a navigational next step, not a mutation
**Decision**: The read-only Alert Deliveries list should use `View alert rules` as its single empty-state CTA, and that CTA should exist only in the empty state.
**Rationale**: `AlertDeliveryResource` is a generated-history surface, so an empty state does not indicate a missing CRUD record the user should create directly. Routing the user to alert rule configuration is the clearest “why is this empty / what do I do next?” step while keeping the list read-only and consistent with monitoring semantics. Once delivery history exists, the explanatory CTA is no longer the primary page action, so the surface keeps its intentional “no header actions” design by explicit exemption rather than adding a persistent header action.
**Alternatives considered**:
- `View alert destinations`: rejected because destinations alone do not explain why no deliveries have fired.
- No CTA: rejected because this feature explicitly removes scaffold-like empty states from in-scope pages.
## R5: Existing test patterns support this pass without browser-only coverage
**Decision**: Use Pest + Livewire list-page tests as the primary verification mechanism, extending existing CTA placement tests and adding per-resource empty-state assertions for content plus capability behavior. Keep dark-mode verification as manual PR/review evidence.
**Rationale**: The repo already uses `assertTableEmptyStateActionsExistInOrder()`, `assertActionDisabled()`, `assertTableActionDisabled()`, response assertions, and action-surface guard tests. This makes the feature testable with focused component and feature coverage, while visual hierarchy and dark mode remain best validated through attached review evidence.
**Alternatives considered**:
- Rely on screenshots only: rejected because the constitution and repo rules require programmatic testing.
- Add browser testing as the default path: rejected because the existing list-page and response-level tests already cover the required behavior at lower cost.
## R6: Action-surface declarations must be updated alongside UI changes
**Decision**: Treat `AlertDeliveryResource::actionSurfaceDeclaration()` as part of the implementation scope, because it currently exempts the `ListEmptyState` slot even though this feature adds a guided empty state.
**Rationale**: The repo enforces the Action Surface Contract through guard tests. Leaving the old exemption in place would create a mismatch between the declared UI contract and the implemented behavior.
**Alternatives considered**:
- Only change visible UI and skip declaration updates: rejected because the guard suite treats declarations as part of the feature contract.
- Broaden the feature to retrofit more action-surface declarations: rejected because only the in-scope resources need updates for this pass.
## R7: CTA outcome and populated-state regressions need explicit automated coverage
**Decision**: The implementation must add targeted automated assertions for (a) empty-state CTA outcomes and (b) representative populated-state behavior, instead of relying on final manual smoke checks alone.
**Rationale**: The spec requires users to reach the intended next step when they activate the CTA, and the constitution requires create-capable surfaces to relocate their primary CTA to the table header when rows exist. Existing guard and placement tests provide the base pattern, but this pass still needs explicit test tasks so those requirements remain enforceable during implementation.
**Alternatives considered**:
- Rely on rendering assertions plus manual QA: rejected because it leaves navigation/queue behavior and populated-state regressions implicit.
- Add full browser coverage for all six surfaces: rejected because focused Pest + Livewire assertions can cover the contract with lower cost.

View File

@ -0,0 +1,176 @@
# Feature Specification: Empty State Consistency Pass
**Feature Branch**: `122-empty-state-consistency`
**Created**: 2026-03-08
**Status**: Ready for Implementation
**Input**: Spec 122 — Empty State Consistency Pass (Unified empty states across all primary list pages)
## Clarifications
### Session 2026-03-08
- Q: What should the single empty-state CTA be for the read-only Alert Deliveries list? → A: Use “View alert rules” as the single CTA.
- Q: How should empty-state CTA visibility behave for members who lack the required capability? → A: Preserve existing per-resource behavior (disabled or hidden), but require explanation when shown disabled.
- Q: Where should the final empty-state configuration live? → A: Prefer resource `table()` definitions for empty-state heading/description/icon/action, and allow page helpers only when technically required.
- Q: How should screenshots and visual QA evidence be handled for this feature? → A: Attach screenshots / visual QA evidence to the PR or review, not as committed repo artifacts.
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**:
- Admin UI list pages: Policies, Backup Sets, Restore Runs, Backup Schedules, Workspaces, Alert Deliveries
- **Data Ownership**:
- Tenant-owned lists: Policies, Backup Sets, Restore Runs, Backup Schedules
- Workspace-owned / workspace-context lists: Workspaces
- Workspace-context monitoring history list: Alert Deliveries (may include tenant-bound rows; tenant entitlement still applies)
- **RBAC**:
- Membership isolation: non-members are deny-as-not-found (404 semantics)
- Capability gating: members without the required capability receive authorization denial (403 on execution); UI may show disabled actions with explanatory tooltip
- Empty-state primary actions MUST remain capability-aware and MUST use the existing UI enforcement helpers already used by each module
## User Scenarios & Testing *(mandatory)*
### User Story 1 - First-run clarity on empty lists (Priority: P1)
As a first-time admin, I want to immediately understand what each list page represents when it has no data yet, and what my next best action is.
**Why this priority**: This is the first impression for new tenants/workspaces and for demos.
**Independent Test**: For each in-scope list page, with zero records, the page renders a complete empty state (icon + heading + description + one primary action).
**Acceptance Scenarios**:
1. **Given** I open an in-scope list page with no records, **When** the table renders, **Then** I see an icon, heading, description, and a single primary action.
2. **Given** I click the primary empty-state action, **When** I have the required permission, **Then** I am taken to the intended next step (or the intended operation is queued) without errors.
---
### User Story 2 - Enterprise-grade consistency during evaluation (Priority: P2)
As a product evaluator, I want empty pages to feel intentional and guided, with consistent hierarchy and tone.
**Why this priority**: In low-data environments, evaluators spend disproportionate time on empty states.
**Independent Test**: Visual and UI assertions confirm consistent presence and ordering of empty-state elements across all in-scope pages.
**Acceptance Scenarios**:
1. **Given** I visit each in-scope list page with no records, **When** I compare the empty states, **Then** they share consistent structure and enterprise-appropriate wording (no scaffold-like presentation).
---
### User Story 3 - Permission-aware guidance (Priority: P3)
As a workspace/tenant member without a required capability, I want to understand what Im missing without the UI leaking access outside my membership scope.
**Why this priority**: Capability-aware UX reduces support load and aligns with RBAC-UX.
**Independent Test**: With membership but missing capability, the empty-state action is not actionable and explains why; server-side authorization remains enforced.
**Acceptance Scenarios**:
1. **Given** I am a valid member but lack the capability for the primary action, **When** I view the empty page, **Then** the CTA is disabled (or hidden where thats the existing pattern) and communicates the missing permission.
2. **Given** I am not entitled to the workspace/tenant scope, **When** I attempt to access the list page, **Then** the app responds as not found (404 semantics).
### Edge Cases
- Empty state must still render correctly in dark mode and light mode.
- Empty state must behave correctly when the tenant context is missing/invalid (records are not shown and the page does not leak data).
- Alert Deliveries list can be empty even when alerts are configured (e.g., no alerts fired yet); copy must set that expectation.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature introduces no new external integrations and no new background work. It only adjusts how existing list pages communicate “no records” states and which single next-step action is presented.
**Constitution alignment (OPS-UX):** This feature MUST NOT change operation UX semantics. Where an empty-state action already queues an operation (e.g., sync), it continues to follow the existing 3-surface feedback contract and uses the canonical operation UX presenter.
**Constitution alignment (RBAC-UX):** This feature MUST NOT relax authorization. It reuses existing capability gating and server-side authorization. Membership remains deny-as-not-found (404 semantics); missing capability remains 403 on execution.
**Constitution alignment (Filament Action Surfaces):** This feature modifies list empty states and therefore MUST satisfy the Action Surface Contract for the “ListEmptyState” slot for each in-scope resource.
**Constitution alignment (UX-001 — Layout & Information Architecture):** Each in-scope list pages empty state must have a specific title, an explanation, and exactly 1 CTA.
### Functional Requirements
- **FR-001**: Each in-scope primary list page MUST render a complete empty state when there are zero records.
- **FR-002**: Each empty state MUST include: icon, heading, description, and exactly one primary action.
- **FR-003**: Empty-state copy MUST be contextual to the module and explain both (a) why its empty and (b) the next best action.
- **FR-004**: Empty-state primary actions MUST remain capability-aware and MUST respect existing UI enforcement rules.
- **FR-004a**: The feature MUST preserve each resources existing CTA visibility model for capability enforcement rather than forcing all empty-state actions into a single visible-disabled or hidden-only pattern.
- **FR-004b**: When an empty-state CTA is shown disabled for a valid member, it MUST explain the missing permission using the resources existing helper text or tooltip pattern.
- **FR-005**: Empty-state rendering MUST remain correct in both light and dark mode.
- **FR-006**: Empty-state configuration MUST be defined in a single, consistent place per list page (to prevent drift and scaffold-like inconsistencies).
- **FR-006a**: Where Filament supports it, empty-state heading, description, icon, and action MUST be defined in the resource `table()` configuration.
- **FR-006b**: Page-level empty-state helpers MAY be used only when technically required, and MUST NOT split responsibility in a way that causes drift between heading/description/icon/action definitions.
- **FR-007**: Out-of-scope resources MUST remain unchanged, including: Finding, Entra Group, Policy Version, Operation Run.
- **FR-008**: The Alert Deliveries empty state MUST use a single navigational CTA labeled “View alert rules” to direct operators to the configuration surface most likely to explain or resolve an empty history.
- **FR-008a**: Alert Deliveries is the sole explicit UX-001 relocation exemption for this feature: the `View alert rules` CTA appears only in the empty state and MUST NOT persist as a table-header action once deliveries exist, because the surface remains intentionally read-only and header-action free.
### Module-specific empty-state copy (baseline direction)
- Policies: “No policies synced yet. Sync your first tenant to see Intune policies here.”
- Backup Sets: “No backup sets. Create a backup set to start protecting your configurations.”
- Restore Runs: “No restore runs. Start a restoration from a backup set.”
- Backup Schedules: “No schedules configured. Set up automated backups.”
- Workspaces: “No workspaces. Create your first workspace.”
- Alert Deliveries: “No alert deliveries. Deliveries appear automatically when alert rules fire.”
### Clarified CTA direction
- Alert Deliveries: use a single CTA labeled “View alert rules”.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Policy list | Admin UI — Policies list page | “Sync from Intune” | View action | View + “More” (as-is) | As-is | “Sync from Intune” | As-is | N/A (read-only details) | No | CTA continues to queue the existing sync operation; no change to operation UX semantics. |
| Backup Sets list | Admin UI — Backup Sets list page | “Create” (existing behavior) | View action | View + “More” | Yes | “Create backup set” | As-is | Create/Edit as-is | Yes (existing audit logger) | Empty state becomes contextual and guided; CTA remains capability gated. |
| Restore Runs list | Admin UI — Restore Runs list page | “Create” (existing behavior) | As-is | As-is | Yes | “New restore run” | As-is | Create wizard as-is | Yes (existing audit logger) | Empty state becomes contextual and guided; CTA remains capability gated. |
| Backup Schedules list | Admin UI — Backup Schedules list page | “New backup schedule” (existing behavior) | Clickable row | View/Edit as-is | Yes | “New backup schedule” | Edit page header as-is | Save/Cancel as-is | No | CTA remains capability-aware; disabled state explains missing permission. |
| Workspaces list | Admin UI — Workspaces list page | “New workspace” (existing behavior) | View action | View/Edit as-is | No | “New workspace” | As-is | Save/Cancel as-is | No | Workspace membership isolation remains deny-as-not-found. |
| Alert Deliveries list | Monitoring — Alert Deliveries list page | None (read-only) | Clickable row | None | None | “View alert rules” (navigation) | View page (read-only) | N/A | No | Adds guided empty state despite read-only list; CTA is a next-step navigation to alert rule configuration, not a mutation. Explicit UX-001 exemption: this CTA does not relocate to the header when records exist. |
### RBAC clarification
- Preserve each resources current capability-aware CTA behavior (disabled vs hidden) rather than standardizing all empty-state actions to one pattern.
### Placement clarification
- Prefer resource `table()` definitions for empty-state heading, description, icon, and action; use page helpers only when technically required.
### Visual QA evidence clarification
- Screenshot and dark-mode visual QA evidence for affected resources must be attached to the PR or review record, not committed into the repository.
## Explicit exemptions
- **Alert Deliveries header-action exemption**: Alert Deliveries remains a read-only monitoring history surface with no persistent header action. Its single CTA (`View alert rules`) exists only while the list is empty. Once deliveries exist, the CTA does not relocate to the header; this is an explicit UX-001 exemption for this feature and must remain documented in the PR or review.
## Acceptance Criteria / Definition of Done
1. All in-scope resources render a full empty state when the table has no records.
2. Each empty state includes icon, heading, description, and exactly one primary CTA.
3. Empty-state actions preserve existing RBAC/UI-enforcement behavior.
4. Visual review confirms centered, intentional hierarchy consistent with the baseline reference pattern.
5. Dark mode renders correctly for every affected list page.
6. Populated-table behavior remains unchanged.
7. Screenshot or visual QA evidence for all affected resources is attached to the PR or review artifact set, rather than committed to the repository.
## Testing Requirements
- Feature/UI test per in-scope resource for empty table rendering.
- Feature/UI test coverage for each in-scope CTA outcome so empty-state actions either navigate to the intended next step or queue the intended existing operation.
- Permission test coverage to ensure CTA visibility or disabled state remains aligned with existing resource behavior.
- Manual visual verification in light and dark mode for every affected list page.
- Automated regression coverage for representative populated-table behavior, including CTA relocation for create-capable surfaces and the documented no-header-action exemption for Alert Deliveries.
- Smoke test confirming populated resources still render their standard tables and existing actions.
- PR/review attachment checklist confirming screenshots or equivalent visual QA evidence were captured for all affected resources.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of in-scope list pages display icon + heading + description + 1 CTA when empty.
- **SC-002**: In a first-run demo with no records, an evaluator can correctly identify the purpose and next action on each in-scope page without guidance.
- **SC-003**: Users without capability can still understand the required permission from the disabled/hidden state messaging, without any cross-scope leakage.
- **SC-004**: No regressions: populated tables continue to render their existing columns, actions, and behaviors unchanged.

View File

@ -0,0 +1,215 @@
# Tasks: Empty State Consistency Pass
**Input**: Design documents from `/specs/122-empty-state-consistency/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/empty-states.openapi.yaml, quickstart.md
**Tests**: Tests are REQUIRED for this runtime behavior change. Use Pest feature and Livewire tests via Sail.
**Operations**: No new `OperationRun` work is required for this feature; existing policy sync operation behavior must remain unchanged.
**RBAC**: No authorization model changes are planned; tasks must preserve existing tenant/workspace membership isolation plus current resource-specific capability behavior for empty-state CTAs.
**Filament UI Action Surfaces**: Tasks must keep each changed list surface compliant with the Action Surface Contract and update `AlertDeliveryResource` so `ListEmptyState` is satisfied instead of exempted.
**Filament UI UX-001**: Tasks must ensure every changed empty state has a specific title, concise explanation, and exactly one CTA, with the CTA relocating to the header when records exist for create-capable surfaces. Alert Deliveries is the documented no-header-action exemption.
**Organization**: Tasks are grouped by user story so each story remains independently testable.
## Phase 1: Setup (Shared Context)
**Purpose**: Align implementation with the approved empty-state contract and current codebase patterns before editing runtime code.
- [X] T001 [P] Review the approved contract and copy in specs/122-empty-state-consistency/spec.md, specs/122-empty-state-consistency/research.md, and specs/122-empty-state-consistency/contracts/empty-states.openapi.yaml
- [X] T002 [P] Inspect canonical resource-level empty-state patterns in app/Filament/Resources/BaselineProfileResource.php, app/Filament/Resources/ReviewPackResource.php, app/Filament/Resources/AlertDestinationResource.php, and app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php
- [X] T003 [P] Review current in-scope list implementations and helper ownership in app/Filament/Resources/PolicyResource.php, app/Filament/Resources/BackupSetResource.php, app/Filament/Resources/RestoreRunResource.php, app/Filament/Resources/BackupScheduleResource.php, app/Filament/Resources/Workspaces/WorkspaceResource.php, and app/Filament/Resources/AlertDeliveryResource.php
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Prepare the shared test and contract guard surfaces that all user stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T004 Extend shared CTA placement and populated-state regression coverage in tests/Feature/Filament/CreateCtaPlacementTest.php so header-vs-empty-state relocation remains a stable baseline for the in-scope create surfaces
- [X] T005 Confirm current action-surface guard expectations in tests/Feature/Guards/ActionSurfaceContractTest.php and the existing `AlertDeliveryResource` exemption in app/Filament/Resources/AlertDeliveryResource.php before changing declarations
**Checkpoint**: Shared placement and contract guard baselines are understood and user-story implementation can begin.
---
## Phase 3: User Story 1 - First-run clarity on empty lists (Priority: P1) 🎯 MVP
**Goal**: Give first-time admins a complete, contextual empty state on every in-scope list page.
**Independent Test**: With zero records on each in-scope list page, the page renders an icon, heading, description, and one primary CTA without changing the underlying workflow destination.
### Tests for User Story 1 ⚠️
> **NOTE**: Add or update these tests first and ensure they fail before implementation.
- [X] T006 [P] [US1] Create empty-state rendering and CTA outcome coverage for policies, backup sets, restore runs, backup schedules, and workspaces in tests/Feature/Filament/EmptyStateConsistencyTest.php
- [X] T007 [P] [US1] Extend alert delivery list coverage for empty-state rendering, CTA destination behavior, and the documented no-header-action exemption in tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php
### Implementation for User Story 1
- [X] T008 [P] [US1] Define the full policies empty state in app/Filament/Resources/PolicyResource.php and reduce helper-only CTA ownership in app/Filament/Resources/PolicyResource/Pages/ListPolicies.php
- [X] T009 [P] [US1] Define the full backup sets empty state in app/Filament/Resources/BackupSetResource.php and reduce helper-only CTA ownership in app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php
- [X] T010 [P] [US1] Define the full restore runs empty state in app/Filament/Resources/RestoreRunResource.php and reduce helper-only CTA ownership in app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php
- [X] T011 [P] [US1] Define the full backup schedules empty state in app/Filament/Resources/BackupScheduleResource.php and reduce helper-only CTA ownership in app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php
- [X] T012 [P] [US1] Define the full workspaces empty state in app/Filament/Resources/Workspaces/WorkspaceResource.php and reduce helper-only CTA ownership in app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
- [X] T013 [US1] Define the read-only alert deliveries empty state, replace the current empty-state exemption, and preserve the documented no-header-action exemption in app/Filament/Resources/AlertDeliveryResource.php
**Checkpoint**: User Story 1 should now render complete empty states across all six in-scope list pages and remain independently testable.
---
## Phase 4: User Story 2 - Enterprise-grade consistency during evaluation (Priority: P2)
**Goal**: Make the six empty states feel like one intentional product system rather than six scaffolds.
**Independent Test**: Compare all six in-scope empty states and verify that copy hierarchy, icon presence, single-CTA structure, and header relocation behavior are consistent.
### Tests for User Story 2 ⚠️
- [X] T014 [P] [US2] Extend tests/Feature/Filament/CreateCtaPlacementTest.php to assert the in-scope create surfaces still expose exactly one empty-state CTA, relocate it to the header when populated, and preserve populated-table behavior after the resource-level refactor
- [X] T015 [P] [US2] Extend tests/Feature/Guards/ActionSurfaceContractTest.php to assert the changed surfaces still satisfy `ListEmptyState`, including `AlertDeliveryResource`, while honoring the documented Alert Deliveries header-action exemption
### Implementation for User Story 2
- [X] T016 [US2] Normalize heading, description, icon, and CTA labels across app/Filament/Resources/PolicyResource.php, app/Filament/Resources/BackupSetResource.php, app/Filament/Resources/RestoreRunResource.php, app/Filament/Resources/BackupScheduleResource.php, app/Filament/Resources/Workspaces/WorkspaceResource.php, and app/Filament/Resources/AlertDeliveryResource.php against the Spec 122 contract
- [X] T017 [US2] Reconcile any remaining split ownership between resource tables and list-page helpers in app/Filament/Resources/PolicyResource/Pages/ListPolicies.php, app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php, app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php, app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php, and app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php
- [ ] T018 [US2] Perform a light/dark visual QA pass for the six in-scope list pages using the review flow described in specs/122-empty-state-consistency/quickstart.md and attach the evidence to the PR/review artifact set
**Checkpoint**: User Story 2 should now demonstrate a deliberate, consistent empty-state system across all in-scope resources.
---
## Phase 5: User Story 3 - Permission-aware guidance (Priority: P3)
**Goal**: Preserve current RBAC behavior so members still get the right disabled/hidden guidance and non-members still get deny-as-not-found behavior.
**Independent Test**: With role-appropriate fixtures, empty-state CTAs remain disabled or hidden according to each resources existing enforcement pattern, and server-side authorization semantics are unchanged.
### Tests for User Story 3 ⚠️
- [X] T019 [P] [US3] Extend disabled-action coverage for policy sync and backup schedule create behavior in tests/Feature/PolicySyncStartSurfaceTest.php and tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php
- [X] T020 [P] [US3] Extend empty-state permission coverage in tests/Feature/Filament/BackupSetUiEnforcementTest.php, tests/Feature/Filament/RestoreRunUiEnforcementTest.php, and tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php
### Implementation for User Story 3
- [X] T021 [US3] Preserve existing capability-aware CTA behavior in app/Filament/Resources/PolicyResource.php, app/Filament/Resources/BackupSetResource.php, app/Filament/Resources/RestoreRunResource.php, app/Filament/Resources/BackupScheduleResource.php, app/Filament/Resources/Workspaces/WorkspaceResource.php, and app/Filament/Resources/AlertDeliveryResource.php while moving empty-state ownership into resource tables
- [X] T022 [US3] Preserve disabled CTA helper text or tooltip messaging for member-without-capability cases in app/Filament/Resources/PolicyResource/Pages/ListPolicies.php, app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php, app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php, and any corresponding resource-level empty-state CTA definitions that surface disabled actions
**Checkpoint**: User Story 3 should now preserve existing authorization semantics while still guiding members who lack permissions.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Final verification, formatting, and review readiness across all stories.
- [X] T023 Run focused Pest coverage with `vendor/bin/sail artisan test --compact` for tests/Feature/Filament/EmptyStateConsistencyTest.php, tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php, tests/Feature/Filament/CreateCtaPlacementTest.php, tests/Feature/PolicySyncStartSurfaceTest.php, tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php, tests/Feature/Filament/BackupSetUiEnforcementTest.php, tests/Feature/Filament/RestoreRunUiEnforcementTest.php, and tests/Feature/Guards/ActionSurfaceContractTest.php to verify CTA outcomes, populated-state regressions, and action-surface compliance
- [X] T024 Run `vendor/bin/sail bin pint --dirty --format agent`
- [ ] T025 Validate the implementation against specs/122-empty-state-consistency/quickstart.md and confirm PR/review evidence includes light/dark screenshots plus populated-table smoke verification for all affected resources
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; this is the MVP.
- **User Story 2 (Phase 4)**: Depends on User Story 1 because consistency normalization assumes the new empty-state content exists.
- **User Story 3 (Phase 5)**: Depends on User Story 1 and can run in parallel with User Story 2 once the new empty-state surfaces exist.
- **Polish (Phase 6)**: Depends on completion of the desired user stories.
### User Story Dependencies
- **US1**: No dependency on other stories; delivers the main feature value.
- **US2**: Depends on US1 surfaces being in place so consistency can be normalized and guarded.
- **US3**: Depends on US1 surfaces being in place so permission behavior can be verified on the final empty-state implementation.
### Within Each User Story
- Tests should be added or updated first and observed failing before implementation.
- Resource `table()` ownership should be established before any helper cleanup.
- Alert Deliveries declaration changes should land with its UI change so guard coverage stays consistent.
- Story-specific verification should complete before moving to the next priority.
### Parallel Opportunities
- T001, T002, and T003 can run in parallel.
- T006 and T007 can run in parallel.
- T008 through T012 can run in parallel because they touch different resource/page pairs.
- T014 and T015 can run in parallel.
- T019 and T020 can run in parallel.
- After US1 implementation begins, US2 and US3 test authoring can be prepared in parallel by different contributors.
---
## Parallel Example: User Story 1
```bash
# Launch the independent resource retrofits together:
Task: "Define the full policies empty state in app/Filament/Resources/PolicyResource.php and reduce helper-only CTA ownership in app/Filament/Resources/PolicyResource/Pages/ListPolicies.php"
Task: "Define the full backup sets empty state in app/Filament/Resources/BackupSetResource.php and reduce helper-only CTA ownership in app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php"
Task: "Define the full restore runs empty state in app/Filament/Resources/RestoreRunResource.php and reduce helper-only CTA ownership in app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php"
Task: "Define the full backup schedules empty state in app/Filament/Resources/BackupScheduleResource.php and reduce helper-only CTA ownership in app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php"
Task: "Define the full workspaces empty state in app/Filament/Resources/Workspaces/WorkspaceResource.php and reduce helper-only CTA ownership in app/Filament/Resources/Workspaces/Pages/ListWorkspaces.php"
```
---
## Parallel Example: User Story 2
```bash
# Launch consistency guard updates together:
Task: "Extend tests/Feature/Filament/CreateCtaPlacementTest.php to assert the in-scope create surfaces still expose exactly one empty-state CTA, relocate it to the header when populated, and preserve populated-table behavior"
Task: "Extend tests/Feature/Guards/ActionSurfaceContractTest.php to assert the changed surfaces still satisfy ListEmptyState, including AlertDeliveryResource"
```
---
## Parallel Example: User Story 3
```bash
# Launch permission-regression updates together:
Task: "Extend disabled-action coverage for policy sync and backup schedule create behavior in tests/Feature/PolicySyncStartSurfaceTest.php and tests/Feature/BackupScheduling/BackupScheduleLifecycleAuthorizationTest.php"
Task: "Extend empty-state permission coverage in tests/Feature/Filament/BackupSetUiEnforcementTest.php, tests/Feature/Filament/RestoreRunUiEnforcementTest.php, and tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational
3. Complete Phase 3: User Story 1
4. Validate the six empty states render correctly with one CTA each
5. Stop and review before broader consistency and RBAC refinement if needed
### Incremental Delivery
1. Ship the core empty-state retrofit across all six surfaces in US1
2. Normalize consistency and guard coverage in US2
3. Harden permission-aware behavior regressions in US3
4. Finish with focused Sail tests, Pint, and review evidence
### Parallel Team Strategy
With multiple contributors:
1. One contributor updates the tenant resource empty states (`Policy`, `BackupSet`, `RestoreRun`)
2. One contributor updates the remaining workspace/monitoring surfaces (`BackupSchedule`, `Workspace`, `AlertDelivery`)
3. One contributor extends guard and placement tests
4. Recombine for authorization regression tests, formatting, and review evidence
---
## Notes
- [P] tasks touch different files and can be executed in parallel.
- User story labels map each task to the corresponding story in spec.md.
- No migrations, new routes, or dependency changes are expected.
- Manual visual QA evidence belongs in PR/review artifacts, not in committed files.

View File

@ -10,8 +10,20 @@
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
function getBackupScheduleEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
it('returns 404 for non-members trying to access schedule lifecycle pages', function () { it('returns 404 for non-members trying to access schedule lifecycle pages', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user] = createUserWithTenant(role: 'owner'); [$user] = createUserWithTenant(role: 'owner');
@ -61,6 +73,22 @@
})->toThrow(AuthorizationException::class); })->toThrow(AuthorizationException::class);
}); });
it('disables backup schedule create in the empty state for members without manage capability', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::test(ListBackupSchedules::class)
->assertTableEmptyStateActionsExistInOrder(['create']);
$action = getBackupScheduleEmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->isDisabled())->toBeTrue();
expect($action?->getTooltip())->toBe('You do not have permission to create backup schedules.');
});
it('treats members without tenant delete capability as forbidden for force delete', function () { it('treats members without tenant delete capability as forbidden for force delete', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager'); [$user, $tenant] = createUserWithTenant(role: 'manager');

View File

@ -4,6 +4,7 @@
use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries; use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
use App\Filament\Resources\AlertRuleResource;
use App\Models\AlertDelivery; use App\Models\AlertDelivery;
use App\Models\AlertDestination; use App\Models\AlertDestination;
use App\Models\AlertRule; use App\Models\AlertRule;
@ -12,8 +13,35 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use Filament\Actions\Action;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; 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 { it('lists only deliveries for entitled tenants', function (): void {
[$user, $tenantA] = createUserWithTenant(role: 'readonly'); [$user, $tenantA] = createUserWithTenant(role: 'readonly');
@ -54,6 +82,58 @@
->assertCanNotSeeTableRecords([$tenantBDelivery]); ->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 { it('returns 404 when a member from another workspace tries to view a delivery', function (): void {
[$user] = createUserWithTenant(role: 'owner'); [$user] = createUserWithTenant(role: 'owner');

View File

@ -93,6 +93,7 @@ function getTableEmptyStateAction($component, string $name): ?\Filament\Actions\
expect($action)->not->toBeNull(); expect($action)->not->toBeNull();
expect($action->isVisible())->toBeTrue(); expect($action->isVisible())->toBeTrue();
expect($action->isDisabled())->toBeFalse(); expect($action->isDisabled())->toBeFalse();
expect($action->getLabel())->toBe('Create backup set');
}); });
test('backup sets list shows empty state create action disabled for members without sync capability', function () { test('backup sets list shows empty state create action disabled for members without sync capability', function () {
@ -109,4 +110,5 @@ function getTableEmptyStateAction($component, string $name): ?\Filament\Actions\
expect($action)->not->toBeNull(); expect($action)->not->toBeNull();
expect($action->isVisible())->toBeTrue(); expect($action->isVisible())->toBeTrue();
expect($action->isDisabled())->toBeTrue(); expect($action->isDisabled())->toBeTrue();
expect($action->getTooltip())->toBe(UiTooltips::insufficientPermission());
}); });

View File

@ -38,6 +38,17 @@ function getHeaderAction(Testable $component, string $name): ?Action
return null; return null;
} }
function getPlacementEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() 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 { it('shows create only in empty state when workspaces table is empty', function (): void {
$workspace = Workspace::factory()->create([ $workspace = Workspace::factory()->create([
'archived_at' => now(), 'archived_at' => now(),
@ -61,6 +72,12 @@ function getHeaderAction(Testable $component, string $name): ?Action
$component = Livewire::test(ListWorkspaces::class) $component = Livewire::test(ListWorkspaces::class)
->assertTableEmptyStateActionsExistInOrder(['create']); ->assertTableEmptyStateActionsExistInOrder(['create']);
expect($component->instance()->getTable()->getEmptyStateActions())->toHaveCount(1);
$emptyStateCreate = getPlacementEmptyStateAction($component, 'create');
expect($emptyStateCreate)->not->toBeNull();
expect($emptyStateCreate?->getLabel())->toBe('New workspace');
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
expect($headerCreate?->isVisible())->toBeFalse(); expect($headerCreate?->isVisible())->toBeFalse();
@ -86,7 +103,8 @@ function getHeaderAction(Testable $component, string $name): ?Action
$this->actingAs($user) $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]); ->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
$component = Livewire::test(ListWorkspaces::class); $component = Livewire::test(ListWorkspaces::class)
->assertCountTableRecords(1);
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
@ -103,6 +121,12 @@ function getHeaderAction(Testable $component, string $name): ?Action
$component = Livewire::test(ListBackupSchedules::class) $component = Livewire::test(ListBackupSchedules::class)
->assertTableEmptyStateActionsExistInOrder(['create']); ->assertTableEmptyStateActionsExistInOrder(['create']);
expect($component->instance()->getTable()->getEmptyStateActions())->toHaveCount(1);
$emptyStateCreate = getPlacementEmptyStateAction($component, 'create');
expect($emptyStateCreate)->not->toBeNull();
expect($emptyStateCreate?->getLabel())->toBe('New backup schedule');
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
expect($headerCreate?->isVisible())->toBeFalse(); expect($headerCreate?->isVisible())->toBeFalse();
@ -128,7 +152,8 @@ function getHeaderAction(Testable $component, string $name): ?Action
'retention_keep_last' => 30, 'retention_keep_last' => 30,
]); ]);
$component = Livewire::test(ListBackupSchedules::class); $component = Livewire::test(ListBackupSchedules::class)
->assertCountTableRecords(1);
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
@ -145,6 +170,12 @@ function getHeaderAction(Testable $component, string $name): ?Action
$component = Livewire::test(ListRestoreRuns::class) $component = Livewire::test(ListRestoreRuns::class)
->assertTableEmptyStateActionsExistInOrder(['create']); ->assertTableEmptyStateActionsExistInOrder(['create']);
expect($component->instance()->getTable()->getEmptyStateActions())->toHaveCount(1);
$emptyStateCreate = getPlacementEmptyStateAction($component, 'create');
expect($emptyStateCreate)->not->toBeNull();
expect($emptyStateCreate?->getLabel())->toBe('New restore run');
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
expect($headerCreate?->isVisible())->toBeFalse(); expect($headerCreate?->isVisible())->toBeFalse();
@ -161,7 +192,8 @@ function getHeaderAction(Testable $component, string $name): ?Action
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
]); ]);
$component = Livewire::test(ListRestoreRuns::class); $component = Livewire::test(ListRestoreRuns::class)
->assertCountTableRecords(1);
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
@ -178,6 +210,12 @@ function getHeaderAction(Testable $component, string $name): ?Action
$component = Livewire::test(ListBackupSets::class) $component = Livewire::test(ListBackupSets::class)
->assertTableEmptyStateActionsExistInOrder(['create']); ->assertTableEmptyStateActionsExistInOrder(['create']);
expect($component->instance()->getTable()->getEmptyStateActions())->toHaveCount(1);
$emptyStateCreate = getPlacementEmptyStateAction($component, 'create');
expect($emptyStateCreate)->not->toBeNull();
expect($emptyStateCreate?->getLabel())->toBe('Create backup set');
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();
expect($headerCreate?->isVisible())->toBeFalse(); expect($headerCreate?->isVisible())->toBeFalse();
@ -194,7 +232,8 @@ function getHeaderAction(Testable $component, string $name): ?Action
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
]); ]);
$component = Livewire::test(ListBackupSets::class); $component = Livewire::test(ListBackupSets::class)
->assertCountTableRecords(1);
$headerCreate = getHeaderAction($component, 'create'); $headerCreate = getHeaderAction($component, 'create');
expect($headerCreate)->not->toBeNull(); expect($headerCreate)->not->toBeNull();

View File

@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Jobs\SyncPoliciesJob;
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 Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function getFeature122EmptyStateTable(Testable $component): Table
{
return $component->instance()->getTable();
}
function getFeature122EmptyStateAction(Testable $component, string $name): ?Action
{
foreach (getFeature122EmptyStateTable($component)->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
function makeWorkspaceListComponent(string $role = 'owner'): Testable
{
$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' => $role,
]);
$user->forceFill([
'last_workspace_id' => (int) $workspace->getKey(),
])->save();
test()->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()]);
return Livewire::test(ListWorkspaces::class);
}
it('defines the policies empty state contract and keeps the sync CTA outcome intact', function (): void {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync'])
->assertSee('No policies synced yet')
->assertSee('Sync your first tenant to see Intune policies here.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-arrow-path');
$action = getFeature122EmptyStateAction($component, 'sync');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('Sync from Intune');
$component
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(SyncPoliciesJob::class);
});
it('defines the backup sets empty state contract and links its CTA to create', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListBackupSets::class)
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No backup sets')
->assertSee('Create a backup set to start protecting your configurations.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No backup sets');
expect($table->getEmptyStateDescription())->toBe('Create a backup set to start protecting your configurations.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-archive-box');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('Create backup set');
expect($action?->getUrl())->toBe(BackupSetResource::getUrl('create', tenant: $tenant));
});
it('defines the restore runs empty state contract and links its CTA to create', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListRestoreRuns::class)
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No restore runs')
->assertSee('Start a restoration from a backup set.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No restore runs');
expect($table->getEmptyStateDescription())->toBe('Start a restoration from a backup set.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-arrow-path-rounded-square');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('New restore run');
expect($action?->getUrl())->toBe(RestoreRunResource::getUrl('create', tenant: $tenant));
});
it('defines the backup schedules empty state contract and links its CTA to create', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::test(ListBackupSchedules::class)
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No schedules configured')
->assertSee('Set up automated backups.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No schedules configured');
expect($table->getEmptyStateDescription())->toBe('Set up automated backups.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-clock');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('New backup schedule');
expect($action?->getUrl())->toBe(BackupScheduleResource::getUrl('create', tenant: $tenant));
});
it('defines the workspaces empty state contract and links its CTA to create', function (): void {
$component = makeWorkspaceListComponent()
->assertTableEmptyStateActionsExistInOrder(['create'])
->assertSee('No workspaces')
->assertSee('Create your first workspace.');
$table = getFeature122EmptyStateTable($component);
expect($table->getEmptyStateHeading())->toBe('No workspaces');
expect($table->getEmptyStateDescription())->toBe('Create your first workspace.');
expect($table->getEmptyStateIcon())->toBe('heroicon-o-squares-2x2');
$action = getFeature122EmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action?->getLabel())->toBe('New workspace');
expect($action?->getUrl())->toBe(WorkspaceResource::getUrl('create'));
});

View File

@ -6,9 +6,11 @@
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Auth\UiTooltips; use App\Support\Auth\UiTooltips;
use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -17,6 +19,17 @@
Http::preventStrayRequests(); Http::preventStrayRequests();
}); });
function getRestoreRunEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
test('non-members are denied access to RestoreRun tenant routes (404)', function () { test('non-members are denied access to RestoreRun tenant routes (404)', function () {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$otherTenant = Tenant::factory()->create(); $otherTenant = Tenant::factory()->create();
@ -80,3 +93,37 @@
expect($restoreRun->fresh()->trashed())->toBeTrue(); expect($restoreRun->fresh()->trashed())->toBeTrue();
}); });
test('restore runs list shows empty state create action enabled for members with manage capability', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'owner');
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListRestoreRuns::class)
->assertTableEmptyStateActionsExistInOrder(['create']);
$action = getRestoreRunEmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action->isVisible())->toBeTrue();
expect($action->isDisabled())->toBeFalse();
expect($action->getLabel())->toBe('New restore run');
});
test('restore runs list shows empty state create action disabled for members without manage capability', function () {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant($tenant, role: 'readonly');
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListRestoreRuns::class)
->assertTableEmptyStateActionsExistInOrder(['create']);
$action = getRestoreRunEmptyStateAction($component, 'create');
expect($action)->not->toBeNull();
expect($action->isVisible())->toBeTrue();
expect($action->isDisabled())->toBeTrue();
expect($action->getTooltip())->toBe(UiTooltips::insufficientPermission());
});

View File

@ -2,6 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles; use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
use App\Filament\Resources\InventoryItemResource; use App\Filament\Resources\InventoryItemResource;
@ -10,6 +12,7 @@
use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies; use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager; use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\BaselineProfile; use App\Models\BaselineProfile;
use App\Models\InventoryItem; use App\Models\InventoryItem;
@ -147,10 +150,13 @@
$profiles = new ActionSurfaceProfileDefinition; $profiles = new ActionSurfaceProfileDefinition;
$declarations = [ $declarations = [
AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(),
BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(),
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(), PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(), OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(), VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(), BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(),
WorkspaceResource::class => WorkspaceResource::actionSurfaceDeclaration(),
]; ];
foreach ($declarations as $className => $declaration) { foreach ($declarations as $className => $declaration) {
@ -161,6 +167,13 @@
} }
}); });
it('documents the guided alert delivery empty state without introducing a list-header CTA', function (): void {
$declaration = AlertDeliveryResource::actionSurfaceDeclaration();
expect((string) ($declaration->slot(ActionSurfaceSlot::ListEmptyState)?->details ?? ''))
->toContain('View alert rules');
});
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void { it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');

View File

@ -4,10 +4,23 @@
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\Policy; use App\Models\Policy;
use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
function getPolicyEmptyStateAction(Testable $component, string $name): ?Action
{
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
if ($action instanceof Action && $action->getName() === $name) {
return $action;
}
}
return null;
}
it('queues policy sync and creates a canonical operation run (no Graph calls in request)', function () { it('queues policy sync and creates a canonical operation run (no Graph calls in request)', function () {
Queue::fake(); Queue::fake();
bindFailHardGraphClient(); bindFailHardGraphClient();
@ -174,9 +187,14 @@
$tenant->makeCurrent(); $tenant->makeCurrent();
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class) $component = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync'])
->assertActionVisible('sync') ->assertActionVisible('sync')
->assertActionDisabled('sync'); ->assertActionDisabled('sync');
$emptyStateAction = getPolicyEmptyStateAction($component, 'sync');
expect($emptyStateAction)->not->toBeNull();
expect($emptyStateAction?->isDisabled())->toBeTrue();
Queue::assertNothingPushed(); Queue::assertNothingPushed();
}); });