diff --git a/app/Filament/Pages/InventoryCoverage.php b/app/Filament/Pages/InventoryCoverage.php index 7a733ad8..f5902764 100644 --- a/app/Filament/Pages/InventoryCoverage.php +++ b/app/Filament/Pages/InventoryCoverage.php @@ -19,6 +19,10 @@ use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use App\Support\Inventory\InventoryPolicyTypeMeta; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use BackedEnum; use Filament\Actions\Action; use Filament\Facades\Filament; @@ -52,6 +56,20 @@ class InventoryCoverage extends Page implements HasTable protected string $view = 'filament.pages.inventory-coverage'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->withDefaults(new ActionSurfaceDefaults( + moreGroupLabel: 'More', + exportIsDefaultBulkActionForReadOnly: false, + )) + ->exempt(ActionSurfaceSlot::ListHeader, 'Inventory coverage stays read-only and uses KPI widgets instead of header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Inventory coverage rows are runtime-derived metadata and intentionally omit inspect affordances.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Derived coverage rows do not expose row actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Derived coverage rows do not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state provides a clear-filters CTA to return to the full coverage matrix.'); + } + public static function shouldRegisterNavigation(): bool { if (Filament::getCurrentPanel()?->getId() === 'admin') { diff --git a/app/Filament/Pages/Monitoring/Operations.php b/app/Filament/Pages/Monitoring/Operations.php index a49c7b7f..adb56627 100644 --- a/app/Filament/Pages/Monitoring/Operations.php +++ b/app/Filament/Pages/Monitoring/Operations.php @@ -14,6 +14,11 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\Operations\OperationLifecyclePolicy; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use BackedEnum; use Filament\Actions\Action; @@ -50,6 +55,20 @@ class Operations extends Page implements HasForms, HasTable // Must be non-static protected string $view = 'filament.pages.monitoring.operations'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->withDefaults(new ActionSurfaceDefaults( + moreGroupLabel: 'More', + exportIsDefaultBulkActionForReadOnly: false, + )) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions preserve scope context and return navigation for the monitoring operations list.') + ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value) + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Operation runs remain immutable on the monitoring list and intentionally omit bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state explains when no operation runs exist for the active workspace scope.') + ->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation opens the canonical tenantless operation detail page, which owns header actions.'); + } + public function mount(): void { $this->navigationContextPayload = is_array(request()->query('nav')) ? request()->query('nav') : null; diff --git a/app/Filament/Pages/NoAccess.php b/app/Filament/Pages/NoAccess.php index 02c2e64e..09389ff9 100644 --- a/app/Filament/Pages/NoAccess.php +++ b/app/Filament/Pages/NoAccess.php @@ -7,6 +7,9 @@ use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use Filament\Actions\Action; use Filament\Forms\Components\TextInput; @@ -27,6 +30,16 @@ class NoAccess extends Page protected string $view = 'filament.pages.no-access'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header provides a create-workspace recovery action when the user has no tenant access yet.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'The no-access page is a singleton recovery surface without record-level inspect affordances.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The no-access page does not render row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The no-access page does not expose bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a dedicated recovery message instead of a list-style empty state.'); + } + /** * @return array */ diff --git a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php index a1d88815..334a0b6f 100644 --- a/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php +++ b/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php @@ -25,6 +25,10 @@ use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\TenantInteractionLane; use App\Support\Tenants\TenantOperabilityQuestion; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\ActionSurfaceDefaults; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern; use Filament\Actions\Action; use Filament\Actions\ActionGroup; @@ -47,6 +51,20 @@ class TenantlessOperationRunViewer extends Page protected string $view = 'filament.pages.operations.tenantless-operation-run-viewer'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::RunLog) + ->withDefaults(new ActionSurfaceDefaults( + moreGroupLabel: 'More', + exportIsDefaultBulkActionForReadOnly: false, + )) + ->exempt(ActionSurfaceSlot::ListHeader, 'Canonical tenantless run viewing is a detail-only page and does not render list header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'The tenantless run viewer is itself the canonical detail destination for a selected operation run.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Detail viewing does not expose bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'The page renders a selected run detail instead of a list empty state.') + ->satisfy(ActionSurfaceSlot::DetailHeader, 'Header keeps scope context, back navigation, refresh, related links, and resumable capture actions when applicable.'); + } + public OperationRun $run; /** diff --git a/app/Filament/Pages/TenantDiagnostics.php b/app/Filament/Pages/TenantDiagnostics.php index e412f54f..1ce207ef 100644 --- a/app/Filament/Pages/TenantDiagnostics.php +++ b/app/Filament/Pages/TenantDiagnostics.php @@ -12,6 +12,9 @@ use App\Support\Auth\Capabilities; use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiTooltips; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use Filament\Actions\Action; use Filament\Pages\Page; @@ -25,6 +28,16 @@ class TenantDiagnostics extends Page protected string $view = 'filament.pages.tenant-diagnostics'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->satisfy(ActionSurfaceSlot::ListHeader, 'Header exposes capability-gated tenant repair actions when inconsistent membership state is detected.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Tenant diagnostics is already the singleton diagnostic surface for the active tenant.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The diagnostics page does not render row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The diagnostics page does not expose bulk actions.') + ->exempt(ActionSurfaceSlot::ListEmptyState, 'Diagnostics content is always rendered instead of a list-style empty state.'); + } + public bool $missingOwner = false; public bool $hasDuplicateMembershipsForCurrentUser = false; diff --git a/app/Filament/Pages/TenantRequiredPermissions.php b/app/Filament/Pages/TenantRequiredPermissions.php index f9270096..91ce2f4e 100644 --- a/app/Filament/Pages/TenantRequiredPermissions.php +++ b/app/Filament/Pages/TenantRequiredPermissions.php @@ -10,6 +10,9 @@ use App\Models\User; use App\Models\WorkspaceMembership; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; +use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; +use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; use Filament\Pages\Page; use Livewire\Attributes\Locked; @@ -27,6 +30,16 @@ class TenantRequiredPermissions extends Page protected string $view = 'filament.pages.tenant-required-permissions'; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration + { + return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) + ->exempt(ActionSurfaceSlot::ListHeader, 'Required permissions keeps guidance, copy flows, and filter reset actions inside body sections instead of page header actions.') + ->exempt(ActionSurfaceSlot::InspectAffordance, 'Required permissions rows are reviewed inline inside the diagnostic matrix and do not open a separate inspect destination.') + ->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Permission rows are read-only and do not expose row-level secondary actions.') + ->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Required permissions does not expose bulk actions.') + ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.'); + } + public string $status = 'missing'; public string $type = 'all'; diff --git a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php index c8cd60f5..b45d2834 100644 --- a/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php +++ b/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php @@ -23,15 +23,9 @@ public static function baseline(): self 'App\\Filament\\Pages\\BreakGlassRecovery' => 'Break-glass flow is governed by dedicated security specs and tests.', 'App\\Filament\\Pages\\ChooseTenant' => 'Tenant chooser has no contract-style table action surface.', 'App\\Filament\\Pages\\ChooseWorkspace' => 'Workspace chooser has no contract-style table action surface.', - 'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage intentionally omits inspect affordances because rows are runtime-derived metadata; spec 124 requires search, sort, filters, and a resettable empty state instead.', - 'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.', - 'App\\Filament\\Pages\\Monitoring\\Operations' => 'Monitoring operations page retrofit deferred; canonical route behavior already covered elsewhere.', - 'App\\Filament\\Pages\\NoAccess' => 'No-access page has no actionable surface by design.', - 'App\\Filament\\Pages\\Operations\\TenantlessOperationRunViewer' => 'Tenantless run viewer retrofit deferred; run-link semantics are covered by monitoring tests.', + 'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts remains exempt because the active admin alerts surface resolves through the cluster entry at /admin/alerts, not this page-class route.', 'App\\Filament\\Pages\\Tenancy\\RegisterTenant' => 'Tenant onboarding route is covered by onboarding/RBAC specs.', 'App\\Filament\\Pages\\TenantDashboard' => 'Dashboard retrofit deferred; widget and summary surfaces are excluded from this contract.', - 'App\\Filament\\Pages\\TenantDiagnostics' => 'Diagnostics page retrofit deferred to tenant-RBAC diagnostics spec.', - 'App\\Filament\\Pages\\TenantRequiredPermissions' => 'Permissions page retrofit deferred; capability checks already enforced by dedicated tests.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.', 'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.', 'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.', diff --git a/tests/Feature/Guards/ActionSurfaceContractTest.php b/tests/Feature/Guards/ActionSurfaceContractTest.php index 47992ff3..b6e3fc72 100644 --- a/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -161,6 +161,12 @@ $profiles = new ActionSurfaceProfileDefinition; $declarations = [ + \App\Filament\Pages\InventoryCoverage::class => \App\Filament\Pages\InventoryCoverage::actionSurfaceDeclaration(), + \App\Filament\Pages\Monitoring\Operations::class => \App\Filament\Pages\Monitoring\Operations::actionSurfaceDeclaration(), + \App\Filament\Pages\NoAccess::class => \App\Filament\Pages\NoAccess::actionSurfaceDeclaration(), + \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class => \App\Filament\Pages\Operations\TenantlessOperationRunViewer::actionSurfaceDeclaration(), + \App\Filament\Pages\TenantDiagnostics::class => \App\Filament\Pages\TenantDiagnostics::actionSurfaceDeclaration(), + \App\Filament\Pages\TenantRequiredPermissions::class => \App\Filament\Pages\TenantRequiredPermissions::actionSurfaceDeclaration(), AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(), BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(), BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(), @@ -234,8 +240,12 @@ it('keeps first-slice trusted-state page action-surface status explicit', function (): void { $baselineExemptions = ActionSurfaceExemptions::baseline(); - expect($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeTrue() - ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toContain('dedicated tests'); + expect(method_exists(\App\Filament\Pages\TenantRequiredPermissions::class, 'actionSurfaceDeclaration'))->toBeTrue() + ->and($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeFalse(); + + expect(method_exists(\App\Filament\Pages\Monitoring\Alerts::class, 'actionSurfaceDeclaration'))->toBeFalse() + ->and($baselineExemptions->hasClass(\App\Filament\Pages\Monitoring\Alerts::class))->toBeTrue() + ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Monitoring\Alerts::class))->toContain('cluster entry'); expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue() ->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests'); @@ -244,6 +254,25 @@ ->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse(); }); +it('keeps cleanup-slice pages declaration-backed without stale baseline exemptions', function (): void { + $baselineExemptions = ActionSurfaceExemptions::baseline(); + + foreach ([ + \App\Filament\Pages\InventoryCoverage::class, + \App\Filament\Pages\Monitoring\Operations::class, + \App\Filament\Pages\NoAccess::class, + \App\Filament\Pages\Operations\TenantlessOperationRunViewer::class, + \App\Filament\Pages\TenantDiagnostics::class, + \App\Filament\Pages\TenantRequiredPermissions::class, + ] as $className) { + expect(method_exists($className, 'actionSurfaceDeclaration')) + ->toBeTrue("{$className} should declare its action surface once enrolled."); + + expect($baselineExemptions->hasClass($className)) + ->toBeFalse("{$className} should not keep a stale baseline exemption after enrollment."); + } +}); + it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner');