feat: action-surface contract inspect affordance + clickable rows #100
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -21,6 +21,7 @@ ## Active Technologies
|
|||||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
|
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4 (080-workspace-managed-tenant-admin)
|
||||||
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
|
- PostgreSQL (via Sail) (080-workspace-managed-tenant-admin)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5 (081-provider-connection-cutover)
|
||||||
|
- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (082-action-surface-contract)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -40,9 +41,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 082-action-surface-contract: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4
|
||||||
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
|
- 081-provider-connection-cutover: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Socialite v5
|
||||||
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
|
- 080-workspace-managed-tenant-admin: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Tailwind v4
|
||||||
- 078-operations-tenantless-canonical: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Filament Infolists (schema-based)
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 1.6.0 → 1.7.0
|
- Version change: 1.7.0 → 1.8.0
|
||||||
- Modified principles:
|
- Modified principles:
|
||||||
- RBAC & UI Enforcement Standards (RBAC-UX) (added Filament action-surface contract gate)
|
- Filament UI — Action Surface Contract (NON-NEGOTIABLE) (added List/Table inspection affordance rule)
|
||||||
- Added sections:
|
- Added sections: None
|
||||||
- Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- ✅ .specify/templates/plan-template.md
|
- ✅ .specify/templates/plan-template.md
|
||||||
@ -144,6 +143,9 @@ ### Filament UI — Action Surface Contract (NON-NEGOTIABLE)
|
|||||||
|
|
||||||
Required surfaces
|
Required surfaces
|
||||||
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
- List/Table MUST define: Header Actions, Row Actions, Bulk Actions, and Empty-State CTA(s).
|
||||||
|
- Inspect affordance (List/Table): Every table MUST provide a record inspection affordance.
|
||||||
|
- Accepted forms: clickable rows via `recordUrl()` (preferred), a dedicated row “View” action, or a primary linked column.
|
||||||
|
- Rule: Do NOT render a lone “View” row action button. If View is the only row action, prefer clickable rows.
|
||||||
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
- View/Detail MUST define Header Actions (Edit + “More” group when applicable).
|
||||||
- Create/Edit MUST provide consistent Save/Cancel UX.
|
- Create/Edit MUST provide consistent Save/Cancel UX.
|
||||||
|
|
||||||
@ -198,4 +200,4 @@ ### Versioning Policy (SemVer)
|
|||||||
- **MINOR**: new principle/section or materially expanded guidance.
|
- **MINOR**: new principle/section or materially expanded guidance.
|
||||||
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
- **MAJOR**: removing/redefining principles in a backward-incompatible way.
|
||||||
|
|
||||||
**Version**: 1.7.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08
|
**Version**: 1.8.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-02-08
|
||||||
|
|||||||
@ -43,7 +43,7 @@ ## Constitution Check
|
|||||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
- Filament UI Action Surface Contract: for any new/modified Filament Resource/RelationManager/Page, define Header/Row/Bulk/Empty-State actions, ensure every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action), keep max 2 visible row actions with the rest in “More”, group bulk actions, require confirmations for destructive actions (typed confirmation for large/bulk where applicable), write audit logs for mutations, enforce RBAC via central helpers (non-member 404, member missing capability 403), and ensure CI blocks merges if the contract is violated or not explicitly exempted
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@ -129,9 +129,9 @@ ## UI Action Matrix *(mandatory when Filament is changed)*
|
|||||||
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
|
||||||
|
|
||||||
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
| 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 |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| Resource/Page/RM | e.g. app/Filament/... | | | | | | | | |
|
| Resource/Page/RM | e.g. app/Filament/... | | e.g. `recordUrl()` / View action / linked column | | | | | | | |
|
||||||
|
|
||||||
### Key Entities *(include if feature involves data)*
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ # Tasks: [FEATURE NAME]
|
|||||||
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
**Filament UI Action Surfaces**: If this feature adds/modifies any Filament Resource / RelationManager / Page, tasks MUST include:
|
||||||
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
- filling the spec’s “UI Action Matrix” for all changed surfaces,
|
||||||
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
- implementing required action surfaces (header/row/bulk/empty-state CTA for lists; header actions for view; consistent save/cancel on create/edit),
|
||||||
|
- ensuring every List/Table has a record inspection affordance (prefer `recordUrl()` clickable rows; do not render a lone View row action),
|
||||||
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
- enforcing the “max 2 visible row actions; everything else in More ActionGroup” rule,
|
||||||
- grouping bulk actions via BulkActionGroup,
|
- grouping bulk actions via BulkActionGroup,
|
||||||
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
- adding confirmations for destructive actions (and typed confirmation where required by scale),
|
||||||
|
|||||||
@ -7,8 +7,11 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -32,6 +35,16 @@ class EntraGroupResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $navigationLabel = 'Groups';
|
protected static ?string $navigationLabel = 'Groups';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
@ -88,15 +101,25 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||||
})
|
})
|
||||||
|
->recordUrl(static fn (EntraGroup $record): ?string => static::canView($record)
|
||||||
|
? static::getUrl('view', ['record' => $record])
|
||||||
|
: null)
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
Tables\Columns\TextColumn::make('display_name')
|
||||||
Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(),
|
->label('Name')
|
||||||
|
->searchable(),
|
||||||
|
Tables\Columns\TextColumn::make('entra_id')
|
||||||
|
->label('Entra ID')
|
||||||
|
->copyable()
|
||||||
|
->toggleable(),
|
||||||
Tables\Columns\TextColumn::make('type')
|
Tables\Columns\TextColumn::make('type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
|
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
|
||||||
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
|
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
|
||||||
Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'),
|
Tables\Columns\TextColumn::make('last_seen_at')
|
||||||
|
->label('Last seen')
|
||||||
|
->since(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
SelectFilter::make('stale')
|
SelectFilter::make('stale')
|
||||||
@ -165,9 +188,7 @@ public static function table(Table $table): Table
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([])
|
||||||
Actions\ViewAction::make(),
|
|
||||||
])
|
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,11 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -34,6 +37,16 @@ class EntraGroupSyncRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $navigationLabel = 'Group Sync Runs';
|
protected static ?string $navigationLabel = 'Group Sync Runs';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
@ -131,9 +144,10 @@ public static function table(Table $table): Table
|
|||||||
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
|
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||||
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
|
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
|
||||||
])
|
])
|
||||||
->actions([
|
->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
|
||||||
Actions\ViewAction::make(),
|
? static::getUrl('view', ['record' => $record])
|
||||||
])
|
: null)
|
||||||
|
->actions([])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,8 +17,11 @@
|
|||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Enums\RelationshipType;
|
use App\Support\Enums\RelationshipType;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -42,6 +45,16 @@ class InventoryItemResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory items list intentionally has no header actions; items are populated via sync.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for inventory items.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; inventory items are created by inventory sync.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
@ -262,9 +275,10 @@ public static function table(Table $table): Table
|
|||||||
->options($categoryOptions)
|
->options($categoryOptions)
|
||||||
->searchable(),
|
->searchable(),
|
||||||
])
|
])
|
||||||
->actions([
|
->recordUrl(static fn (Model $record): ?string => static::canView($record)
|
||||||
Actions\ViewAction::make(),
|
? static::getUrl('view', ['record' => $record])
|
||||||
])
|
: null)
|
||||||
|
->actions([])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,8 +12,11 @@
|
|||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
use Filament\Infolists\Components\ViewEntry;
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
@ -39,6 +42,16 @@ class InventorySyncRunResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
@ -189,9 +202,10 @@ public static function table(Table $table): Table
|
|||||||
->label('Errors')
|
->label('Errors')
|
||||||
->numeric(),
|
->numeric(),
|
||||||
])
|
])
|
||||||
->actions([
|
->recordUrl(static fn (Model $record): ?string => static::canView($record)
|
||||||
Actions\ViewAction::make(),
|
? static::getUrl('view', ['record' => $record])
|
||||||
])
|
: null)
|
||||||
|
->actions([])
|
||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,11 @@
|
|||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\RunDetailPolling;
|
use App\Support\OpsUx\RunDetailPolling;
|
||||||
use App\Support\OpsUx\RunDurationInsights;
|
use App\Support\OpsUx\RunDurationInsights;
|
||||||
|
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 App\Support\Workspaces\WorkspaceContext;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -41,12 +46,40 @@ class OperationRunResource extends Resource
|
|||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
|
protected static bool $isGloballySearchable = false;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-queue-list';
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Operations';
|
protected static ?string $navigationLabel = 'Operations';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||||
|
->withDefaults(new ActionSurfaceDefaults(
|
||||||
|
moreGroupLabel: 'More',
|
||||||
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
))
|
||||||
|
->exempt(
|
||||||
|
ActionSurfaceSlot::ListHeader,
|
||||||
|
'Run-log list intentionally has no list-header actions; navigation actions are provided by Monitoring shell pages.',
|
||||||
|
)
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->exempt(
|
||||||
|
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
|
'Operation runs are immutable records; bulk export is deferred and tracked outside this retrofit.',
|
||||||
|
)
|
||||||
|
->exempt(
|
||||||
|
ActionSurfaceSlot::ListEmptyState,
|
||||||
|
'Empty-state action is intentionally omitted; users can adjust filters/date range in-page.',
|
||||||
|
)
|
||||||
|
->exempt(
|
||||||
|
ActionSurfaceSlot::DetailHeader,
|
||||||
|
'Tenantless detail view is informational and currently has no header actions.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||||
|
|||||||
@ -23,6 +23,10 @@
|
|||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -52,6 +56,17 @@ class PolicyResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action: Sync from Intune.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List page defines an empty-state CTA.')
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides header actions when applicable.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
@ -536,7 +551,9 @@ public static function table(Table $table): Table
|
|||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply(),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
BulkActionGroup::make([
|
BulkActionGroup::make([
|
||||||
@ -888,7 +905,7 @@ public static function table(Table $table): Table
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||||
->apply(),
|
->apply(),
|
||||||
]),
|
])->label('More'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,71 +22,79 @@ class ListPolicies extends ListRecords
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [$this->makeSyncAction()];
|
||||||
UiEnforcement::forAction(
|
}
|
||||||
Actions\Action::make('sync')
|
|
||||||
->label('Sync from Intune')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->action(function (self $livewire): void {
|
|
||||||
$tenant = Tenant::current();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
protected function getTableEmptyStateActions(): array
|
||||||
abort(404);
|
{
|
||||||
}
|
return [$this->makeSyncAction()];
|
||||||
|
}
|
||||||
|
|
||||||
$requestedTypes = array_map(
|
private function makeSyncAction(): Actions\Action
|
||||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
{
|
||||||
config('tenantpilot.supported_policy_types', [])
|
return UiEnforcement::forAction(
|
||||||
);
|
Actions\Action::make('sync')
|
||||||
|
->label('Sync from Intune')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->action(function (self $livewire): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
sort($requestedTypes);
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
$requestedTypes = array_map(
|
||||||
$opService = app(OperationRunService::class);
|
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||||
$opRun = $opService->ensureRun(
|
config('tenantpilot.supported_policy_types', [])
|
||||||
tenant: $tenant,
|
);
|
||||||
type: 'policy.sync',
|
|
||||||
inputs: [
|
|
||||||
'scope' => 'all',
|
|
||||||
'types' => $requestedTypes,
|
|
||||||
],
|
|
||||||
initiator: $user
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
sort($requestedTypes);
|
||||||
Notification::make()
|
|
||||||
->title('Policy sync already active')
|
|
||||||
->body('This operation is already queued or running.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('View run')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
/** @var OperationRunService $opService */
|
||||||
}
|
$opService = app(OperationRunService::class);
|
||||||
|
$opRun = $opService->ensureRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.sync',
|
||||||
|
inputs: [
|
||||||
|
'scope' => 'all',
|
||||||
|
'types' => $requestedTypes,
|
||||||
|
],
|
||||||
|
initiator: $user
|
||||||
|
);
|
||||||
|
|
||||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
SyncPoliciesJob::dispatch((int) $tenant->getKey(), $requestedTypes, null, $opRun);
|
Notification::make()
|
||||||
});
|
->title('Policy sync already active')
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
->body('This operation is already queued or running.')
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
})
|
|
||||||
)
|
return;
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
}
|
||||||
->tooltip('You do not have permission to sync policies.')
|
|
||||||
->destructive()
|
$opService->dispatchOrFail($opRun, function () use ($tenant, $requestedTypes, $opRun): void {
|
||||||
->apply(),
|
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.')
|
||||||
|
->destructive()
|
||||||
|
->apply();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,10 @@
|
|||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\Rbac\UiTooltips;
|
use App\Support\Rbac\UiTooltips;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -24,6 +28,16 @@ class VersionsRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
protected static string $relationship = 'versions';
|
protected static string $relationship = 'versions';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Versions sub-list intentionally has no header actions.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Only two row actions are present, so no secondary row menu is needed.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for version restore safety.')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'No inline empty-state action is exposed in this embedded relation manager.');
|
||||||
|
}
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
$restoreToIntune = Actions\Action::make('restore_to_intune')
|
||||||
|
|||||||
@ -23,6 +23,10 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -51,6 +55,17 @@ class PolicyVersionResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->exempt(ActionSurfaceSlot::ListHeader, 'Policy versions list intentionally has no header actions; versions are created by sync and captures.')
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; versions appear after policy sync/capture workflows.')
|
||||||
|
->exempt(ActionSurfaceSlot::DetailHeader, 'View page header actions are intentionally minimal for now.');
|
||||||
|
}
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
public static function infolist(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -494,8 +509,10 @@ public static function table(Table $table): Table
|
|||||||
->trueLabel('All')
|
->trueLabel('All')
|
||||||
->falseLabel('Archived'),
|
->falseLabel('Archived'),
|
||||||
])
|
])
|
||||||
|
->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record)
|
||||||
|
? static::getUrl('view', ['record' => $record])
|
||||||
|
: null)
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
(function (): Actions\Action {
|
(function (): Actions\Action {
|
||||||
$action = Actions\Action::make('restore_via_wizard')
|
$action = Actions\Action::make('restore_via_wizard')
|
||||||
|
|||||||
120
app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php
Normal file
120
app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
|
||||||
|
final class ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string, ActionSurfaceSlotRequirement>
|
||||||
|
*/
|
||||||
|
private array $slots = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, ActionSurfaceExemption>
|
||||||
|
*/
|
||||||
|
private array $exemptions = [];
|
||||||
|
|
||||||
|
public ActionSurfaceDefaults $defaults;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $version,
|
||||||
|
public readonly ActionSurfaceComponentType $componentType,
|
||||||
|
public readonly ActionSurfaceProfile $profile,
|
||||||
|
?ActionSurfaceDefaults $defaults = null,
|
||||||
|
) {
|
||||||
|
$this->defaults = $defaults ?? new ActionSurfaceDefaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function make(
|
||||||
|
ActionSurfaceComponentType $componentType,
|
||||||
|
ActionSurfaceProfile $profile,
|
||||||
|
int $version = 1,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
version: $version,
|
||||||
|
componentType: $componentType,
|
||||||
|
profile: $profile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forResource(ActionSurfaceProfile $profile, int $version = 1): self
|
||||||
|
{
|
||||||
|
return self::make(ActionSurfaceComponentType::Resource, $profile, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forPage(ActionSurfaceProfile $profile, int $version = 1): self
|
||||||
|
{
|
||||||
|
return self::make(ActionSurfaceComponentType::Page, $profile, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forRelationManager(ActionSurfaceProfile $profile, int $version = 1): self
|
||||||
|
{
|
||||||
|
return self::make(ActionSurfaceComponentType::RelationManager, $profile, $version);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withDefaults(ActionSurfaceDefaults $defaults): self
|
||||||
|
{
|
||||||
|
$this->defaults = $defaults;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSlot(ActionSurfaceSlot $slot, ActionSurfaceSlotRequirement $requirement): self
|
||||||
|
{
|
||||||
|
$this->slots[$slot->value] = $requirement;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function satisfy(
|
||||||
|
ActionSurfaceSlot $slot,
|
||||||
|
?string $details = null,
|
||||||
|
bool $requiresTypedConfirmation = false,
|
||||||
|
): self {
|
||||||
|
return $this->setSlot($slot, ActionSurfaceSlotRequirement::satisfied($details, $requiresTypedConfirmation));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exempt(
|
||||||
|
ActionSurfaceSlot $slot,
|
||||||
|
string $reason,
|
||||||
|
?string $trackingRef = null,
|
||||||
|
?string $details = null,
|
||||||
|
): self {
|
||||||
|
$this->setSlot($slot, ActionSurfaceSlotRequirement::exempt($details));
|
||||||
|
$this->exemptions[$slot->value] = new ActionSurfaceExemption($slot, $reason, $trackingRef);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function slot(ActionSurfaceSlot $slot): ?ActionSurfaceSlotRequirement
|
||||||
|
{
|
||||||
|
return $this->slots[$slot->value] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exemption(ActionSurfaceSlot $slot): ?ActionSurfaceExemption
|
||||||
|
{
|
||||||
|
return $this->exemptions[$slot->value] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ActionSurfaceSlotRequirement>
|
||||||
|
*/
|
||||||
|
public function slots(): array
|
||||||
|
{
|
||||||
|
return $this->slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, ActionSurfaceExemption>
|
||||||
|
*/
|
||||||
|
public function exemptions(): array
|
||||||
|
{
|
||||||
|
return $this->exemptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Support/Ui/ActionSurface/ActionSurfaceDefaults.php
Normal file
13
app/Support/Ui/ActionSurface/ActionSurfaceDefaults.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
final class ActionSurfaceDefaults
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $moreGroupLabel = 'More',
|
||||||
|
public readonly bool $exportIsDefaultBulkActionForReadOnly = true,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
|
|
||||||
|
final class ActionSurfaceDiscoveredComponent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfacePanelScope> $panelScopes
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $className,
|
||||||
|
public readonly ActionSurfaceComponentType $componentType,
|
||||||
|
public readonly array $panelScopes,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function hasPanelScope(ActionSurfacePanelScope $scope): bool
|
||||||
|
{
|
||||||
|
return in_array($scope, $this->panelScopes, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
292
app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php
Normal file
292
app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
|
use RecursiveDirectoryIterator;
|
||||||
|
use RecursiveIteratorIterator;
|
||||||
|
use SplFileInfo;
|
||||||
|
|
||||||
|
final class ActionSurfaceDiscovery
|
||||||
|
{
|
||||||
|
private string $basePath;
|
||||||
|
|
||||||
|
private string $appPath;
|
||||||
|
|
||||||
|
private string $routesPath;
|
||||||
|
|
||||||
|
private string $adminPanelProviderPath;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
?string $basePath = null,
|
||||||
|
?string $appPath = null,
|
||||||
|
?string $routesPath = null,
|
||||||
|
?string $adminPanelProviderPath = null,
|
||||||
|
) {
|
||||||
|
$this->basePath = $basePath ?? base_path();
|
||||||
|
$this->appPath = $appPath ?? app_path();
|
||||||
|
$this->routesPath = $routesPath ?? base_path('routes/web.php');
|
||||||
|
$this->adminPanelProviderPath = $adminPanelProviderPath ?? app_path('Providers/Filament/AdminPanelProvider.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ActionSurfaceDiscoveredComponent>
|
||||||
|
*/
|
||||||
|
public function discover(): array
|
||||||
|
{
|
||||||
|
$adminScopedClasses = $this->discoverAdminScopedClasses();
|
||||||
|
|
||||||
|
/** @var array<string, ActionSurfaceDiscoveredComponent> $components */
|
||||||
|
$components = [];
|
||||||
|
|
||||||
|
foreach ($this->resourceFiles() as $path) {
|
||||||
|
$className = $this->classNameFromPath($path);
|
||||||
|
|
||||||
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
||||||
|
className: $className,
|
||||||
|
componentType: ActionSurfaceComponentType::Resource,
|
||||||
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->pageFiles() as $path) {
|
||||||
|
$className = $this->classNameFromPath($path);
|
||||||
|
|
||||||
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
||||||
|
className: $className,
|
||||||
|
componentType: ActionSurfaceComponentType::Page,
|
||||||
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->relationManagerFiles() as $path) {
|
||||||
|
$className = $this->classNameFromPath($path);
|
||||||
|
|
||||||
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
||||||
|
className: $className,
|
||||||
|
componentType: ActionSurfaceComponentType::RelationManager,
|
||||||
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($components);
|
||||||
|
|
||||||
|
return array_values($components);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $adminScopedClasses
|
||||||
|
* @return array<int, ActionSurfacePanelScope>
|
||||||
|
*/
|
||||||
|
private function panelScopesFor(string $className, array $adminScopedClasses): array
|
||||||
|
{
|
||||||
|
$scopes = [ActionSurfacePanelScope::Tenant];
|
||||||
|
|
||||||
|
if (in_array($className, $adminScopedClasses, true)) {
|
||||||
|
$scopes[] = ActionSurfacePanelScope::Admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function resourceFiles(): array
|
||||||
|
{
|
||||||
|
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
|
||||||
|
if (! str_ends_with($path, 'Resource.php')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($path, '/Pages/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($path, '/RelationManagers/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function pageFiles(): array
|
||||||
|
{
|
||||||
|
return $this->collectPhpFiles($this->appPath.'/Filament/Pages', static function (string $path): bool {
|
||||||
|
return str_ends_with($path, '.php');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function relationManagerFiles(): array
|
||||||
|
{
|
||||||
|
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
|
||||||
|
if (! str_contains($path, '/RelationManagers/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_ends_with($path, 'RelationManager.php');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(string): bool $filter
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function collectPhpFiles(string $directory, callable $filter): array
|
||||||
|
{
|
||||||
|
if (! is_dir($directory)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new RecursiveIteratorIterator(
|
||||||
|
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
);
|
||||||
|
|
||||||
|
$paths = [];
|
||||||
|
|
||||||
|
/** @var SplFileInfo $file */
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
if (! $file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = str_replace('\\', '/', $file->getPathname());
|
||||||
|
|
||||||
|
if (! $filter($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths[] = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($paths);
|
||||||
|
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function discoverAdminScopedClasses(): array
|
||||||
|
{
|
||||||
|
$classes = array_merge(
|
||||||
|
$this->parseFilamentClassReferences($this->adminPanelProviderPath),
|
||||||
|
$this->parseFilamentClassReferences($this->routesPath),
|
||||||
|
);
|
||||||
|
|
||||||
|
$classes = array_values(array_unique(array_filter($classes, static function (string $className): bool {
|
||||||
|
return str_starts_with($className, 'App\\Filament\\');
|
||||||
|
})));
|
||||||
|
|
||||||
|
sort($classes);
|
||||||
|
|
||||||
|
return $classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function parseFilamentClassReferences(string $filePath): array
|
||||||
|
{
|
||||||
|
if (! is_file($filePath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = file_get_contents($filePath);
|
||||||
|
|
||||||
|
if (! is_string($contents) || $contents === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$imports = $this->parseUseStatements($contents);
|
||||||
|
|
||||||
|
preg_match_all('/\\\\?([A-Z][A-Za-z0-9_\\\\]*)::(?:class|registerRoutes)\b/', $contents, $matches);
|
||||||
|
|
||||||
|
$classes = [];
|
||||||
|
|
||||||
|
foreach ($matches[1] as $token) {
|
||||||
|
$resolved = $this->resolveClassToken($token, $imports);
|
||||||
|
|
||||||
|
if ($resolved === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$classes[] = $resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function parseUseStatements(string $contents): array
|
||||||
|
{
|
||||||
|
preg_match_all('/^use\s+([^;]+);/m', $contents, $matches);
|
||||||
|
|
||||||
|
$imports = [];
|
||||||
|
|
||||||
|
foreach ($matches[1] as $importExpression) {
|
||||||
|
$normalized = trim($importExpression);
|
||||||
|
|
||||||
|
if (! str_contains($normalized, '\\')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = preg_split('/\s+as\s+/i', $normalized);
|
||||||
|
$fqcn = ltrim($parts[0], '\\');
|
||||||
|
|
||||||
|
$alias = $parts[1] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($alias) || trim($alias) === '') {
|
||||||
|
$segments = explode('\\', $fqcn);
|
||||||
|
$alias = end($segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($alias) || trim($alias) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imports[trim($alias)] = $fqcn;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $imports;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $imports
|
||||||
|
*/
|
||||||
|
private function resolveClassToken(string $token, array $imports): ?string
|
||||||
|
{
|
||||||
|
$token = ltrim(trim($token), '\\');
|
||||||
|
|
||||||
|
if ($token === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($token, '\\')) {
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $imports[$token] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function classNameFromPath(string $path): string
|
||||||
|
{
|
||||||
|
$normalizedPath = str_replace('\\', '/', $path);
|
||||||
|
$normalizedAppPath = str_replace('\\', '/', $this->appPath);
|
||||||
|
$relative = ltrim(substr($normalizedPath, strlen($normalizedAppPath)), '/');
|
||||||
|
|
||||||
|
return 'App\\'.str_replace('/', '\\', substr($relative, 0, -4));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Support/Ui/ActionSurface/ActionSurfaceExemption.php
Normal file
21
app/Support/Ui/ActionSurface/ActionSurfaceExemption.php
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
|
||||||
|
final class ActionSurfaceExemption
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ActionSurfaceSlot $slot,
|
||||||
|
public readonly string $reason,
|
||||||
|
public readonly ?string $trackingRef = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function hasReason(): bool
|
||||||
|
{
|
||||||
|
return trim($this->reason) !== '';
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
Normal file
69
app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
final class ActionSurfaceExemptions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $componentReasons
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $componentReasons,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function baseline(): self
|
||||||
|
{
|
||||||
|
return new self([
|
||||||
|
// Baseline allowlist for legacy surfaces. Keep shrinking this list.
|
||||||
|
'App\\Filament\\Pages\\Auth\\Login' => 'Auth entry page is out-of-scope for action-surface retrofits in spec 082.',
|
||||||
|
'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\\DriftLanding' => 'Drift landing retrofit deferred to drift-focused UI spec.',
|
||||||
|
'App\\Filament\\Pages\\InventoryCoverage' => 'Inventory coverage page retrofit deferred; no action-surface declaration yet.',
|
||||||
|
'App\\Filament\\Pages\\InventoryLanding' => 'Inventory landing page retrofit deferred; no action-surface declaration yet.',
|
||||||
|
'App\\Filament\\Pages\\Monitoring\\Alerts' => 'Monitoring alerts page retrofit deferred; no action-surface declaration yet.',
|
||||||
|
'App\\Filament\\Pages\\Monitoring\\AuditLog' => 'Monitoring audit-log 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\\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\\BackupScheduleResource' => 'Backup schedule resource retrofit deferred to backup scheduling track.',
|
||||||
|
'App\\Filament\\Resources\\BackupScheduleResource\\RelationManagers\\BackupScheduleRunsRelationManager' => 'Backup schedule runs relation manager retrofit deferred to backup scheduling track.',
|
||||||
|
'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.',
|
||||||
|
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
|
||||||
|
'App\\Filament\\Resources\\FindingResource' => 'Finding resource retrofit deferred to drift track.',
|
||||||
|
'App\\Filament\\Resources\\ProviderConnectionResource' => 'Provider connection resource retrofit deferred to provider-connection track.',
|
||||||
|
'App\\Filament\\Resources\\RestoreRunResource' => 'Restore run resource retrofit deferred to restore track.',
|
||||||
|
'App\\Filament\\Resources\\TenantResource' => 'Tenant resource retrofit deferred to tenant administration track.',
|
||||||
|
'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.',
|
||||||
|
'App\\Filament\\Resources\\Workspaces\\RelationManagers\\WorkspaceMembershipsRelationManager' => 'Workspace memberships relation manager retrofit deferred to workspace RBAC track.',
|
||||||
|
'App\\Filament\\Resources\\Workspaces\\WorkspaceResource' => 'Workspace resource retrofit deferred to workspace management track.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return $this->componentReasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reasonForClass(string $className): ?string
|
||||||
|
{
|
||||||
|
return $this->componentReasons[$className] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasClass(string $className): bool
|
||||||
|
{
|
||||||
|
return array_key_exists($className, $this->componentReasons);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
|
||||||
|
final class ActionSurfaceProfileDefinition
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, ActionSurfaceSlot>
|
||||||
|
*/
|
||||||
|
public function requiredSlots(ActionSurfaceProfile $profile): array
|
||||||
|
{
|
||||||
|
return match ($profile) {
|
||||||
|
ActionSurfaceProfile::CrudListAndEdit,
|
||||||
|
ActionSurfaceProfile::CrudListAndView => [
|
||||||
|
ActionSurfaceSlot::ListHeader,
|
||||||
|
ActionSurfaceSlot::InspectAffordance,
|
||||||
|
ActionSurfaceSlot::ListRowMoreMenu,
|
||||||
|
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
|
ActionSurfaceSlot::ListEmptyState,
|
||||||
|
ActionSurfaceSlot::DetailHeader,
|
||||||
|
],
|
||||||
|
ActionSurfaceProfile::ListOnlyReadOnly => [
|
||||||
|
ActionSurfaceSlot::ListHeader,
|
||||||
|
ActionSurfaceSlot::InspectAffordance,
|
||||||
|
ActionSurfaceSlot::ListRowMoreMenu,
|
||||||
|
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
|
ActionSurfaceSlot::ListEmptyState,
|
||||||
|
],
|
||||||
|
ActionSurfaceProfile::RunLog => [
|
||||||
|
ActionSurfaceSlot::ListHeader,
|
||||||
|
ActionSurfaceSlot::InspectAffordance,
|
||||||
|
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
|
ActionSurfaceSlot::ListEmptyState,
|
||||||
|
ActionSurfaceSlot::DetailHeader,
|
||||||
|
],
|
||||||
|
ActionSurfaceProfile::RelationManager => [
|
||||||
|
ActionSurfaceSlot::ListHeader,
|
||||||
|
ActionSurfaceSlot::InspectAffordance,
|
||||||
|
ActionSurfaceSlot::ListRowMoreMenu,
|
||||||
|
ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
|
ActionSurfaceSlot::ListEmptyState,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresExportDefaultBulk(ActionSurfaceProfile $profile): bool
|
||||||
|
{
|
||||||
|
return in_array($profile, [
|
||||||
|
ActionSurfaceProfile::ListOnlyReadOnly,
|
||||||
|
ActionSurfaceProfile::RunLog,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlotStatus;
|
||||||
|
|
||||||
|
final class ActionSurfaceSlotRequirement
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ActionSurfaceSlotStatus $status,
|
||||||
|
public readonly ?string $details = null,
|
||||||
|
public readonly bool $requiresTypedConfirmation = false,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function satisfied(?string $details = null, bool $requiresTypedConfirmation = false): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
status: ActionSurfaceSlotStatus::Satisfied,
|
||||||
|
details: $details,
|
||||||
|
requiresTypedConfirmation: $requiresTypedConfirmation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function exempt(?string $details = null): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
status: ActionSurfaceSlotStatus::Exempt,
|
||||||
|
details: $details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExempt(): bool
|
||||||
|
{
|
||||||
|
return $this->status === ActionSurfaceSlotStatus::Exempt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
|
||||||
|
final class ActionSurfaceValidationIssue
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $className,
|
||||||
|
public readonly string $message,
|
||||||
|
public readonly ?ActionSurfaceSlot $slot = null,
|
||||||
|
public readonly ?string $hint = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function format(): string
|
||||||
|
{
|
||||||
|
$line = $this->className;
|
||||||
|
|
||||||
|
if ($this->slot !== null) {
|
||||||
|
$line .= ' ['.$this->slot->value.']';
|
||||||
|
}
|
||||||
|
|
||||||
|
$line .= ': '.$this->message;
|
||||||
|
|
||||||
|
if ($this->hint !== null && trim($this->hint) !== '') {
|
||||||
|
$line .= ' (hint: '.$this->hint.')';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
final class ActionSurfaceValidationResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $issues,
|
||||||
|
private readonly int $componentCount,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function hasIssues(): bool
|
||||||
|
{
|
||||||
|
return $this->issues !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ActionSurfaceValidationIssue>
|
||||||
|
*/
|
||||||
|
public function issues(): array
|
||||||
|
{
|
||||||
|
return $this->issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function componentCount(): int
|
||||||
|
{
|
||||||
|
return $this->componentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatForAssertion(): string
|
||||||
|
{
|
||||||
|
if (! $this->hasIssues()) {
|
||||||
|
return sprintf('Validated %d action-surface components with no issues.', $this->componentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = array_map(
|
||||||
|
static fn (ActionSurfaceValidationIssue $issue): string => '- '.$issue->format(),
|
||||||
|
$this->issues,
|
||||||
|
);
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
"Action Surface Contract violations (%d/%d):\n%s",
|
||||||
|
count($this->issues),
|
||||||
|
$this->componentCount,
|
||||||
|
implode("\n", $lines),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
299
app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
Normal file
299
app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
|
||||||
|
final class ActionSurfaceValidator
|
||||||
|
{
|
||||||
|
private ActionSurfaceDiscovery $discovery;
|
||||||
|
|
||||||
|
private ActionSurfaceProfileDefinition $profileDefinition;
|
||||||
|
|
||||||
|
private ActionSurfaceExemptions $exemptions;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
?ActionSurfaceDiscovery $discovery = null,
|
||||||
|
?ActionSurfaceProfileDefinition $profileDefinition = null,
|
||||||
|
?ActionSurfaceExemptions $exemptions = null,
|
||||||
|
) {
|
||||||
|
$this->discovery = $discovery ?? new ActionSurfaceDiscovery;
|
||||||
|
$this->profileDefinition = $profileDefinition ?? new ActionSurfaceProfileDefinition;
|
||||||
|
$this->exemptions = $exemptions ?? ActionSurfaceExemptions::baseline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, ActionSurfaceDiscoveredComponent>
|
||||||
|
*/
|
||||||
|
public function discoveredComponents(): array
|
||||||
|
{
|
||||||
|
return $this->discovery->discover();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function withBaselineExemptions(): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
discovery: new ActionSurfaceDiscovery,
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: ActionSurfaceExemptions::baseline(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(): ActionSurfaceValidationResult
|
||||||
|
{
|
||||||
|
return $this->validateComponents($this->discoveredComponents());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceDiscoveredComponent> $components
|
||||||
|
*/
|
||||||
|
public function validateComponents(array $components): ActionSurfaceValidationResult
|
||||||
|
{
|
||||||
|
$issues = [];
|
||||||
|
|
||||||
|
foreach ($components as $component) {
|
||||||
|
if (! class_exists($component->className)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $component->className,
|
||||||
|
message: 'Discovered class does not exist or is not autoloadable.',
|
||||||
|
hint: 'Verify namespace/path and run composer dump-autoload if needed.',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$declaration = $this->resolveDeclarationForComponent($component, $issues);
|
||||||
|
|
||||||
|
if ($declaration === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($declaration->componentType !== $component->componentType) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $component->className,
|
||||||
|
message: sprintf(
|
||||||
|
'Declaration component type mismatch (%s declared, %s discovered).',
|
||||||
|
$declaration->componentType->name,
|
||||||
|
$component->componentType->name,
|
||||||
|
),
|
||||||
|
hint: 'Use ActionSurfaceDeclaration::forResource/forPage/forRelationManager consistently.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($declaration->defaults->moreGroupLabel !== 'More') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $component->className,
|
||||||
|
message: sprintf(
|
||||||
|
'Invalid more-group label "%s".',
|
||||||
|
$declaration->defaults->moreGroupLabel,
|
||||||
|
),
|
||||||
|
hint: 'Set ActionSurfaceDefaults->moreGroupLabel to "More".',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateRequiredSlots($component->className, $declaration, $issues);
|
||||||
|
$this->validateExemptions($component->className, $declaration, $issues);
|
||||||
|
$this->validateExportDefaults($component->className, $declaration, $issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ActionSurfaceValidationResult(
|
||||||
|
issues: $issues,
|
||||||
|
componentCount: count($components),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function resolveDeclarationForComponent(
|
||||||
|
ActionSurfaceDiscoveredComponent $component,
|
||||||
|
array &$issues,
|
||||||
|
): ?ActionSurfaceDeclaration {
|
||||||
|
$className = $component->className;
|
||||||
|
|
||||||
|
if (! method_exists($className, 'actionSurfaceDeclaration')) {
|
||||||
|
$this->validateClassExemptionOrFail($className, $issues);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$declaration = $className::actionSurfaceDeclaration();
|
||||||
|
} catch (\Throwable $throwable) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'actionSurfaceDeclaration() threw an exception: '.$throwable->getMessage(),
|
||||||
|
hint: 'Ensure actionSurfaceDeclaration() is static and does not depend on request state.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $declaration instanceof ActionSurfaceDeclaration) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'actionSurfaceDeclaration() must return ActionSurfaceDeclaration.',
|
||||||
|
hint: 'Return ActionSurfaceDeclaration::forResource/forPage/forRelationManager(...).',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $declaration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateClassExemptionOrFail(string $className, array &$issues): void
|
||||||
|
{
|
||||||
|
$reason = $this->exemptions->reasonForClass($className);
|
||||||
|
|
||||||
|
if ($reason === null) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Missing action-surface declaration and no component exemption exists.',
|
||||||
|
hint: 'Add actionSurfaceDeclaration() or register a baseline exemption with a non-empty reason.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($reason) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Component exemption reason must be non-empty.',
|
||||||
|
hint: 'Provide a concrete, non-empty justification in ActionSurfaceExemptions::baseline().',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateRequiredSlots(
|
||||||
|
string $className,
|
||||||
|
ActionSurfaceDeclaration $declaration,
|
||||||
|
array &$issues,
|
||||||
|
): void {
|
||||||
|
foreach ($this->profileDefinition->requiredSlots($declaration->profile) as $slot) {
|
||||||
|
$requirement = $declaration->slot($slot);
|
||||||
|
|
||||||
|
if ($requirement === null) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: $slot,
|
||||||
|
message: 'Required slot is not declared.',
|
||||||
|
hint: 'Declare slot as satisfied or exempt with a reason.',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $requirement->isExempt()) {
|
||||||
|
if ($slot === ActionSurfaceSlot::InspectAffordance) {
|
||||||
|
$this->validateInspectAffordanceSlot($className, $requirement, $issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exemption = $declaration->exemption($slot);
|
||||||
|
|
||||||
|
if ($exemption === null || ! $exemption->hasReason()) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: $slot,
|
||||||
|
message: 'Slot is marked exempt but exemption reason is missing or empty.',
|
||||||
|
hint: 'Use ->exempt(slot, "reason") with a non-empty reason.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateInspectAffordanceSlot(
|
||||||
|
string $className,
|
||||||
|
ActionSurfaceSlotRequirement $requirement,
|
||||||
|
array &$issues,
|
||||||
|
): void {
|
||||||
|
$mode = $requirement->details;
|
||||||
|
|
||||||
|
if (! is_string($mode) || trim($mode) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: ActionSurfaceSlot::InspectAffordance,
|
||||||
|
message: 'Inspect affordance must declare how inspection is provided (clickable_row, view_action, or primary_link_column).',
|
||||||
|
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ActionSurfaceInspectAffordance::tryFrom($mode) instanceof ActionSurfaceInspectAffordance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: ActionSurfaceSlot::InspectAffordance,
|
||||||
|
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
|
||||||
|
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateExemptions(
|
||||||
|
string $className,
|
||||||
|
ActionSurfaceDeclaration $declaration,
|
||||||
|
array &$issues,
|
||||||
|
): void {
|
||||||
|
foreach ($declaration->exemptions() as $slotValue => $exemption) {
|
||||||
|
if (! $exemption->hasReason()) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: ActionSurfaceSlot::from($slotValue),
|
||||||
|
message: 'Exemption reason must be non-empty.',
|
||||||
|
hint: 'Provide a concise reason for each exempted slot.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateExportDefaults(
|
||||||
|
string $className,
|
||||||
|
ActionSurfaceDeclaration $declaration,
|
||||||
|
array &$issues,
|
||||||
|
): void {
|
||||||
|
if (! $this->profileDefinition->requiresExportDefaultBulk($declaration->profile)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($declaration->defaults->exportIsDefaultBulkActionForReadOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulkExemption = $declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup);
|
||||||
|
|
||||||
|
if ($bulkExemption instanceof ActionSurfaceExemption && $bulkExemption->hasReason()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
slot: ActionSurfaceSlot::ListBulkMoreGroup,
|
||||||
|
message: 'ReadOnly/RunLog profile disables Export default but no bulk-slot exemption reason was provided.',
|
||||||
|
hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface\Enums;
|
||||||
|
|
||||||
|
enum ActionSurfaceComponentType: string
|
||||||
|
{
|
||||||
|
case Resource = 'resource';
|
||||||
|
case Page = 'page';
|
||||||
|
case RelationManager = 'relation_manager';
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface\Enums;
|
||||||
|
|
||||||
|
enum ActionSurfaceInspectAffordance: string
|
||||||
|
{
|
||||||
|
case ClickableRow = 'clickable_row';
|
||||||
|
case ViewAction = 'view_action';
|
||||||
|
case PrimaryLinkColumn = 'primary_link_column';
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface\Enums;
|
||||||
|
|
||||||
|
enum ActionSurfacePanelScope: string
|
||||||
|
{
|
||||||
|
case Tenant = 'tenant';
|
||||||
|
case Admin = 'admin';
|
||||||
|
}
|
||||||
14
app/Support/Ui/ActionSurface/Enums/ActionSurfaceProfile.php
Normal file
14
app/Support/Ui/ActionSurface/Enums/ActionSurfaceProfile.php
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface\Enums;
|
||||||
|
|
||||||
|
enum ActionSurfaceProfile: string
|
||||||
|
{
|
||||||
|
case CrudListAndEdit = 'crud_list_and_edit';
|
||||||
|
case CrudListAndView = 'crud_list_and_view';
|
||||||
|
case ListOnlyReadOnly = 'list_only_read_only';
|
||||||
|
case RunLog = 'run_log';
|
||||||
|
case RelationManager = 'relation_manager';
|
||||||
|
}
|
||||||
15
app/Support/Ui/ActionSurface/Enums/ActionSurfaceSlot.php
Normal file
15
app/Support/Ui/ActionSurface/Enums/ActionSurfaceSlot.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface\Enums;
|
||||||
|
|
||||||
|
enum ActionSurfaceSlot: string
|
||||||
|
{
|
||||||
|
case ListHeader = 'ListHeader';
|
||||||
|
case InspectAffordance = 'InspectAffordance';
|
||||||
|
case ListRowMoreMenu = 'ListRowMoreMenu';
|
||||||
|
case ListBulkMoreGroup = 'ListBulkMoreGroup';
|
||||||
|
case ListEmptyState = 'ListEmptyState';
|
||||||
|
case DetailHeader = 'DetailHeader';
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Ui\ActionSurface\Enums;
|
||||||
|
|
||||||
|
enum ActionSurfaceSlotStatus: string
|
||||||
|
{
|
||||||
|
case Satisfied = 'Satisfied';
|
||||||
|
case Exempt = 'Exempt';
|
||||||
|
}
|
||||||
26
docs/ui/action-surface-contract.md
Normal file
26
docs/ui/action-surface-contract.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Action surface contract
|
||||||
|
|
||||||
|
This project enforces a small “action surface contract” for Filament Resources / Pages / RelationManagers to keep table UIs consistent, quiet, and safe.
|
||||||
|
|
||||||
|
## Inspect affordance (required)
|
||||||
|
|
||||||
|
Any list-style surface that exposes records must provide an **inspect affordance** so an admin can open a record.
|
||||||
|
|
||||||
|
Accepted implementations:
|
||||||
|
|
||||||
|
- **Clickable rows** (preferred): set `recordUrl()` for the table.
|
||||||
|
- **View action**: a `ViewAction` in the row actions.
|
||||||
|
- **Primary link column**: a column that is clearly the primary affordance to open the record.
|
||||||
|
|
||||||
|
### Rule: no lone “View” button
|
||||||
|
|
||||||
|
Avoid rendering a table that only has a single `View` row action. This creates visual noise and adds an unnecessary Actions column.
|
||||||
|
|
||||||
|
Preferred approach:
|
||||||
|
|
||||||
|
- Make the row clickable via `recordUrl()` and set `actions([])` so no Actions column is rendered.
|
||||||
|
|
||||||
|
## RBAC / safety
|
||||||
|
|
||||||
|
- If the current user cannot inspect a record, `recordUrl()` must return `null` for that record.
|
||||||
|
- UI visibility is not authorization; always enforce permissions at the policy / resource level.
|
||||||
37
specs/082-action-surface-contract/checklists/requirements.md
Normal file
37
specs/082-action-surface-contract/checklists/requirements.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Specification Quality Checklist: Action Surface Contract + CI Enforcement
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-02-08
|
||||||
|
**Feature**: specs/082-action-surface-contract/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
|
||||||
|
|
||||||
|
- Validation run: 2026-02-08
|
||||||
|
- No placeholders remain; no [NEEDS CLARIFICATION] markers.
|
||||||
|
- Requirements include explicit RBAC semantics (not-found vs forbidden), safety confirmations, and exemption rules.
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://tenantatlas.local/schemas/action-surface-declaration.schema.json",
|
||||||
|
"title": "ActionSurfaceDeclaration",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["version", "componentType", "profile", "defaults", "slots"],
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"componentType": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["Resource", "Page", "RelationManager"]
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"CrudListAndEdit",
|
||||||
|
"CrudListAndView",
|
||||||
|
"ListOnlyReadOnly",
|
||||||
|
"RunLog"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["moreGroupLabel"],
|
||||||
|
"properties": {
|
||||||
|
"moreGroupLabel": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "More"
|
||||||
|
},
|
||||||
|
"exportIsDefaultBulkActionForReadOnly": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"slots": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"ListHeader": { "$ref": "#/$defs/slotRequirement" },
|
||||||
|
"ListRowPrimary": { "$ref": "#/$defs/slotRequirement" },
|
||||||
|
"ListRowMoreMenu": { "$ref": "#/$defs/slotRequirement" },
|
||||||
|
"ListBulkMoreGroup": { "$ref": "#/$defs/slotRequirement" },
|
||||||
|
"ListEmptyState": { "$ref": "#/$defs/slotRequirement" },
|
||||||
|
"DetailHeader": { "$ref": "#/$defs/slotRequirement" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exemptions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/exemption" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"slotRequirement": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["status"],
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["Satisfied", "Exempt"]
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"requiresTypedConfirmation": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exemption": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["slot", "reason"],
|
||||||
|
"properties": {
|
||||||
|
"slot": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ListHeader",
|
||||||
|
"ListRowPrimary",
|
||||||
|
"ListRowMoreMenu",
|
||||||
|
"ListBulkMoreGroup",
|
||||||
|
"ListEmptyState",
|
||||||
|
"DetailHeader"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"trackingRef": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
specs/082-action-surface-contract/data-model.md
Normal file
81
specs/082-action-surface-contract/data-model.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# Data Model — Action Surface Contract
|
||||||
|
|
||||||
|
This feature introduces a small, explicit model for declaring and validating action surfaces.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### ActionSurfaceDeclaration
|
||||||
|
|
||||||
|
Represents the contract declaration attached to a Filament component.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `version` (int): schema version for forward compatibility (start at `1`).
|
||||||
|
- `componentType` (enum): `Resource | Page | RelationManager`.
|
||||||
|
- `className` (string): fully qualified class name (filled by validator when reporting).
|
||||||
|
- `panelScope` (enum): `Tenant | Admin` (filled by validator based on discovery context).
|
||||||
|
- `profile` (enum): contract profile (see below).
|
||||||
|
- `slots` (map<ActionSurfaceSlot, SlotRequirement>): required and optional slots for this component.
|
||||||
|
- `exemptions` (list<ActionSurfaceExemption>): explicit exemptions with reason.
|
||||||
|
- `defaults` (ActionSurfaceDefaults): shared UX defaults (e.g., `moreGroupLabel`).
|
||||||
|
|
||||||
|
### ActionSurfaceSlot (enum)
|
||||||
|
|
||||||
|
Discrete UI locations that may contain actions.
|
||||||
|
|
||||||
|
Proposed baseline slots:
|
||||||
|
- `ListHeader`
|
||||||
|
- `ListRowPrimary`
|
||||||
|
- `ListRowMoreMenu`
|
||||||
|
- `ListBulkMoreGroup`
|
||||||
|
- `ListEmptyState`
|
||||||
|
- `DetailHeader`
|
||||||
|
|
||||||
|
(Profiles determine which slots are required.)
|
||||||
|
|
||||||
|
### SlotRequirement
|
||||||
|
|
||||||
|
Requirement status for a single slot.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `status` (enum): `Satisfied | Exempt`.
|
||||||
|
- `details` (optional string): freeform explanation or expected actions.
|
||||||
|
- `requiresTypedConfirmation` (bool, default false): indicates the actions in this slot require typed confirmation.
|
||||||
|
|
||||||
|
### ActionSurfaceExemption
|
||||||
|
|
||||||
|
Represents a justified opt-out for a required slot.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `slot` (ActionSurfaceSlot)
|
||||||
|
- `reason` (string, required, non-empty)
|
||||||
|
- `trackingRef` (optional string)
|
||||||
|
|
||||||
|
### ActionSurfaceDefaults
|
||||||
|
|
||||||
|
Shared defaults enforced/checked by CI.
|
||||||
|
|
||||||
|
**Fields**:
|
||||||
|
- `moreGroupLabel` (string, required): must be `More`.
|
||||||
|
- `exportIsDefaultBulkActionForReadOnly` (bool): default expectation for ReadOnly + RunLog profiles.
|
||||||
|
|
||||||
|
## Profiles (enum)
|
||||||
|
|
||||||
|
Profiles map component archetypes to required slots.
|
||||||
|
|
||||||
|
Proposed profiles (initial set):
|
||||||
|
- `CrudListAndEdit`: list + edit page exists.
|
||||||
|
- `CrudListAndView`: list + view page exists.
|
||||||
|
- `ListOnlyReadOnly`: list-only, no edit/view.
|
||||||
|
- `RunLog`: operational log list (read-only, expects Export bulk action by default).
|
||||||
|
|
||||||
|
## Validation rules (CI)
|
||||||
|
|
||||||
|
For each in-scope class:
|
||||||
|
- Must define an action surface declaration.
|
||||||
|
- Must select a profile.
|
||||||
|
- For every slot required by that profile:
|
||||||
|
- slot must be present with `Satisfied`, OR
|
||||||
|
- slot must be marked `Exempt` and there must be a matching exemption with non-empty reason.
|
||||||
|
- Defaults must include `moreGroupLabel = "More"`.
|
||||||
|
|
||||||
|
Note: The validator is intentionally declarative; it validates the presence and completeness of the declaration (and exemptions), not the exact runtime Filament action configuration.
|
||||||
108
specs/082-action-surface-contract/plan.md
Normal file
108
specs/082-action-surface-contract/plan.md
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# Implementation Plan: Action Surface Contract + CI Enforcement
|
||||||
|
|
||||||
|
**Branch**: `082-action-surface-contract` | **Date**: 2026-02-08 | **Spec**: specs/082-action-surface-contract/spec.md
|
||||||
|
**Input**: Feature specification from specs/082-action-surface-contract/spec.md
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Introduce a declarative “Action Surface Declaration” for every in-scope admin UI component (Resources, Pages, embedded relationship sub-lists), enforce it via a deterministic Pest guard in CI, and add representative runtime tests for constitution-critical action behavior.
|
||||||
|
|
||||||
|
Key behavior:
|
||||||
|
- Contract profiles define minimum required action slots (list header/row/bulk/empty-state, detail header).
|
||||||
|
- Declarations must satisfy each required slot or explicitly exempt it with a reason.
|
||||||
|
- CI fails on missing declarations, missing required slots, or empty exemption reasons.
|
||||||
|
- Representative runtime tests verify grouping/confirmation/RBAC/canonical-run-link behavior on selected surfaces.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.x
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Storage**: PostgreSQL (Sail)
|
||||||
|
**Testing**: Pest v4 (PHPUnit 12 runner)
|
||||||
|
**Target Platform**: Web application (Laravel Sail for local; Dokploy for deploy)
|
||||||
|
**Project Type**: Laravel monolith (admin console via Filament panels)
|
||||||
|
**Performance Goals**: Contract validation completes in < 1s locally/CI (pure PHP, no network)
|
||||||
|
**Constraints**:
|
||||||
|
- Deterministic CI enforcement (no Livewire browser interactions)
|
||||||
|
- No new external calls introduced
|
||||||
|
- Must preserve RBAC semantics (non-member 404; member missing capability 403)
|
||||||
|
- Must not change panel architecture (tenant/admin/system panels remain)
|
||||||
|
- No deep runtime introspection of every Filament action in CI for this iteration
|
||||||
|
**Scale/Scope**: ~100 Filament classes (Resources/Pages/RelationManagers); enforcement is repo-wide within scope
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: N/A (this feature is UI governance; no inventory model changes)
|
||||||
|
- Read/write separation: N/A (no new domain writes introduced by the validator)
|
||||||
|
- Graph contract path: PASS (no new Graph calls)
|
||||||
|
- Deterministic capabilities: PASS (capability rules stay registry-driven)
|
||||||
|
- RBAC-UX: PASS (enforcement continues to use central UI helpers; server-side remains authoritative)
|
||||||
|
- Run observability: PASS (validator runs in CI only; no operations started)
|
||||||
|
- Data minimization: PASS (no new storage)
|
||||||
|
- Badge semantics (BADGE-001): N/A
|
||||||
|
- Filament UI Action Surface Contract: PASS (this feature implements the CI gate itself)
|
||||||
|
|
||||||
|
## Plan Phases
|
||||||
|
|
||||||
|
### Phase 0 — Research (output: research.md)
|
||||||
|
|
||||||
|
Decide and document:
|
||||||
|
- How to enumerate in-scope classes for tenant + admin panels (excluding widgets)
|
||||||
|
- How declarations are attached (static method vs attributes vs config)
|
||||||
|
- What “slots” exist and how exemptions are represented (reason required; tracking optional)
|
||||||
|
- How the Pest guard reports actionable failures
|
||||||
|
|
||||||
|
### Phase 1 — Design (outputs: data-model.md, contracts/*, quickstart.md)
|
||||||
|
|
||||||
|
Design artifacts:
|
||||||
|
- Data model for declarations, slots, exemptions, and profiles
|
||||||
|
- JSON schema contract for the declaration payload (for future tooling)
|
||||||
|
- Quickstart explaining how to add a declaration + how to run the guard
|
||||||
|
|
||||||
|
### Phase 2 — Planning (output: tasks.md later via /speckit.tasks)
|
||||||
|
|
||||||
|
Implementation plan will include:
|
||||||
|
- New support classes under `app/Support/Ui/ActionSurface/**`
|
||||||
|
- Pest guard test under `tests/Feature/Guards/**`
|
||||||
|
- Minimal retrofit for existing “bulk none” surfaces via declarations + targeted exemptions (repo-green first)
|
||||||
|
- Representative runtime assertions for action grouping, confirmation, and canonical run links
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/082-action-surface-contract/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Resources/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ └── System/ # out-of-scope for this feature’s validator
|
||||||
|
├── Providers/Filament/ # panel providers (tenant/admin/system)
|
||||||
|
└── Support/
|
||||||
|
├── Rbac/ # UiEnforcement + WorkspaceUiEnforcement (existing)
|
||||||
|
└── Ui/ActionSurface/ # NEW: profiles, slots, declarations, validator
|
||||||
|
|
||||||
|
tests/
|
||||||
|
└── Feature/
|
||||||
|
└── Guards/ # NEW: Action surface contract CI gate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel monolith. Action-surface enforcement ships as support-domain classes + a Pest guard.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations required.
|
||||||
34
specs/082-action-surface-contract/quickstart.md
Normal file
34
specs/082-action-surface-contract/quickstart.md
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
# Quickstart — Action Surface Contract
|
||||||
|
|
||||||
|
This quickstart describes how developers will satisfy the Spec 082 contract once the validator is implemented.
|
||||||
|
|
||||||
|
## Add a declaration to a Filament component
|
||||||
|
|
||||||
|
For an in-scope class (Resource, Page, RelationManager), add a static declaration method.
|
||||||
|
|
||||||
|
Example (shape only; exact namespaces/classes defined in implementation):
|
||||||
|
- Add `public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration`
|
||||||
|
- Select a `profile`
|
||||||
|
- Declare required slots as satisfied
|
||||||
|
- Keep defaults aligned (group label `More`)
|
||||||
|
|
||||||
|
## Exempt a slot
|
||||||
|
|
||||||
|
If a required slot is intentionally not present:
|
||||||
|
- Mark the slot as `Exempt`
|
||||||
|
- Add an exemption entry with a non-empty `reason`
|
||||||
|
- Optionally provide a `trackingRef`
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
- Run the contract guard test:
|
||||||
|
- `vendor/bin/sail artisan test --compact --filter=ActionSurfaceContract`
|
||||||
|
|
||||||
|
## Interpreting failures
|
||||||
|
|
||||||
|
Failures will point to:
|
||||||
|
- the class missing a declaration
|
||||||
|
- which required slots are missing
|
||||||
|
- which exemptions are invalid (missing/empty reason)
|
||||||
|
|
||||||
|
The failure output is intended to be actionable, similar to existing guard tests in `tests/Feature/Guards/*`.
|
||||||
104
specs/082-action-surface-contract/research.md
Normal file
104
specs/082-action-surface-contract/research.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Research — Action Surface Contract + CI Enforcement
|
||||||
|
|
||||||
|
This document resolves planning unknowns for Spec 082 and records key design decisions.
|
||||||
|
|
||||||
|
## Decision 1 — Scope enumeration (Tenant + Admin panels; exclude Widgets)
|
||||||
|
|
||||||
|
**Decision**: The validator enumerates in-scope Filament classes by scanning `app/Filament/**` for Resources, Pages, and RelationManagers and then filtering to:
|
||||||
|
- `app/Filament/Resources/**` (tenant panel discovery)
|
||||||
|
- `app/Filament/Pages/**` (tenant panel discovery)
|
||||||
|
- `app/Filament/RelationManagers/**` (used by resources)
|
||||||
|
- plus any admin-panel explicit resources/pages registered via `app/Providers/Filament/AdminPanelProvider.php`
|
||||||
|
|
||||||
|
Widgets are explicitly excluded.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Matches clarified spec scope (Tenant + Admin panels; Resources/Pages/RelationManagers; exclude Widgets).
|
||||||
|
- Deterministic and fast (filesystem + reflection; no UI runtime required).
|
||||||
|
- Aligns with existing repo patterns (guard tests that scan filesystem and fail with actionable output).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Discovering via Filament panel runtime discovery: rejected (adds runtime coupling and can be non-deterministic in CI).
|
||||||
|
- Enumerating only by filesystem: accepted as baseline, but we still need a small allowlist for admin explicit registrations.
|
||||||
|
|
||||||
|
## Decision 2 — Declaration attachment mechanism
|
||||||
|
|
||||||
|
**Decision**: Each in-scope class provides a declaration via a static method (e.g., `public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration`).
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- PHP-first, simple, no attribute parsing or config conventions.
|
||||||
|
- Easy to require/validate by reflection.
|
||||||
|
- Plays well with existing Filament static patterns (Resources already use statics like `$recordTitleAttribute`).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- PHP 8 attributes: rejected for now (slower adoption, harder to keep structured without additional tooling).
|
||||||
|
- Central registry config: rejected (drifts away from the component, discourages ownership).
|
||||||
|
|
||||||
|
## Decision 3 — Contract enforcement: “declare or exempt” + representative runtime checks
|
||||||
|
|
||||||
|
**Decision**: CI enforces that each in-scope class has a declaration and that every required slot is either marked satisfied or has an explicit exemption with a non-empty reason.
|
||||||
|
|
||||||
|
The validator does not attempt to prove that every underlying Filament `table()` / `getHeaderActions()` actually contains those actions. Instead, representative runtime tests cover constitution-critical behavior (grouping, confirmation, RBAC semantics, canonical run links).
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Deterministic and robust across Filament internal changes.
|
||||||
|
- Avoids brittle source parsing and avoids needing Livewire/Filament runtime contexts.
|
||||||
|
- Still forces explicit human intent on each surface, which is the governance goal.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- AST/static parsing of PHP to verify `->actions()` / `->bulkActions()` usage: rejected (high complexity and brittleness).
|
||||||
|
- Instantiating Filament objects and introspecting action definitions for every surface in CI: rejected initially (risk of boot/DI coupling). Representative runtime tests are used instead for critical guarantees.
|
||||||
|
|
||||||
|
## Decision 4 — Profiles and required “slots”
|
||||||
|
|
||||||
|
**Decision**: Model “profiles” that map to component archetypes (e.g., list-only, list+detail, read-only list, run-log list). Each profile defines required slots:
|
||||||
|
- List header actions
|
||||||
|
- List row actions (max 2 visible; rest in “More”)
|
||||||
|
- List bulk actions (grouped; “More” label)
|
||||||
|
- List empty-state actions
|
||||||
|
- Detail header actions (if the component has a detail view/edit/view page)
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Keeps enforcement simple and consistent.
|
||||||
|
- Allows targeted exceptions by profile without exploding per-component logic.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- One giant universal profile: rejected (too many exemptions, low signal).
|
||||||
|
|
||||||
|
## Decision 5 — Exemption policy
|
||||||
|
|
||||||
|
**Decision**: Exemptions require:
|
||||||
|
- a non-empty reason string
|
||||||
|
- optional tracking reference (issue/PR link)
|
||||||
|
- no deadline requirement
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Matches clarified spec.
|
||||||
|
- Keeps CI deterministic (no time-based expiry).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Required expiry dates: rejected (creates “time bombs” in CI).
|
||||||
|
|
||||||
|
## Decision 6 — Standard grouping label and safe-default Export
|
||||||
|
|
||||||
|
**Decision**:
|
||||||
|
- The canonical group label for secondary row actions and bulk action groups is `More`.
|
||||||
|
- For `ReadOnly` + `RunLog` profiles, the default expected safe bulk action is `Export` (otherwise exemption required).
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Matches clarified spec.
|
||||||
|
- Provides a consistent, predictable UX baseline.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Allowing arbitrary labels: rejected (inconsistent UX; harder to audit).
|
||||||
|
|
||||||
|
## Decision 7 — Typed confirmation threshold
|
||||||
|
|
||||||
|
**Decision**: The contract model includes a `requiresTypedConfirmation` flag for actions that are destructive/irreversible, affect > 25 records, or change access/credentials/safety gates.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Matches clarified spec.
|
||||||
|
- Keeps enforcement declarative and reviewable.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Always requiring typed confirmations: rejected (too heavy for routine admin workflows).
|
||||||
191
specs/082-action-surface-contract/spec.md
Normal file
191
specs/082-action-surface-contract/spec.md
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
# Feature Specification: Action Surface Contract + CI Enforcement
|
||||||
|
|
||||||
|
**Feature Branch**: `082-action-surface-contract`
|
||||||
|
**Created**: 2026-02-08
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Spec 082 — Action Surface Contract + CI Enforcement"
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-02-08
|
||||||
|
|
||||||
|
- Q: Group label standard: “More” vs “Actions” → A: Use “More” as the standard label for secondary row actions and bulk action groups.
|
||||||
|
- Q: Standard safe bulk action for ReadOnly/RunLog: allow Export everywhere? → A: Yes — Export is the default safe bulk action for ReadOnly and RunLog surfaces when data exists; otherwise an explicit exemption with reason is required.
|
||||||
|
- Q: Exemption policy: time-boxed vs reason-only? → A: Exemptions must include a non-empty reason; an optional tracking link/reference may be included; no deadline is required.
|
||||||
|
- Q: Audit policy: destructive-only vs all mutations? → A: Audit events are required for all mutations and for operation-start triggers (run/sync/verify/dispatch).
|
||||||
|
- Q: Validator scope/discovery: which components are in-scope for CI enforcement? → A: Tenant + Admin panels; validate Resources, Pages, and embedded relationship sub-lists; exclude Widgets.
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Prevent incomplete UI action surfaces (Priority: P1)
|
||||||
|
|
||||||
|
As a developer, when I add or modify an admin-console UI component (list, detail, embedded relationship sub-list), I must explicitly declare its expected action surface (or explicitly exempt parts with a reason), so that automated checks prevent regressions before merge.
|
||||||
|
|
||||||
|
**Why this priority**: This is the regression gate. Without it, incomplete action surfaces ship repeatedly and create inconsistent UX and RBAC drift.
|
||||||
|
|
||||||
|
**Independent Test**: Add a minimal new UI component without an action surface declaration and confirm the automated gate fails with a precise message; then add a declaration (or exemption) and confirm the gate passes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a new or modified in-scope UI component without an action surface declaration, **When** validation runs, **Then** it fails with a message naming the component and the missing required slots.
|
||||||
|
2. **Given** a component with a complete declaration (or exemptions with reasons), **When** validation runs, **Then** it passes.
|
||||||
|
3. **Given** a component uses an exemption, **When** validation runs, **Then** it fails if the exemption reason is missing or empty.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Consistent, enterprise-grade actions and empty-state guidance (Priority: P2)
|
||||||
|
|
||||||
|
As an admin-console user, I see predictable and consistent actions across lists and details: primary actions are obvious, secondary actions are grouped, bulk actions exist where appropriate, and empty states provide an actionable next step.
|
||||||
|
|
||||||
|
**Why this priority**: Reduces training cost and operational mistakes; improves speed and confidence in admin workflows.
|
||||||
|
|
||||||
|
**Independent Test**: Pick one CRUD-style list and one run-log style list; verify each has the expected action areas (header/row/bulk/empty-state; detail header actions) and that ordering/grouping conventions are consistent.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a list view with records, **When** a user inspects available actions, **Then** they see at most two visible row actions and all secondary actions are grouped under a consistent label.
|
||||||
|
2. **Given** a list view with zero records, **When** the page is shown, **Then** it includes at least one CTA that helps resolve the empty state.
|
||||||
|
3. **Given** a list view that supports selection, **When** the user selects one or more records, **Then** at least one bulk action is available or the UI explicitly documents why bulk is intentionally not offered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Predictable RBAC behavior for members vs non-members (Priority: P3)
|
||||||
|
|
||||||
|
As a security reviewer, I need consistent authorization semantics across all in-scope actions:
|
||||||
|
- A non-member must not discover tenant/workspace surfaces (deny-as-not-found).
|
||||||
|
- A member without the required capability must be prevented from completing the action (deny-as-forbidden), and the UI must communicate why.
|
||||||
|
|
||||||
|
**Why this priority**: Prevents information disclosure and capability drift; keeps the product aligned with least privilege.
|
||||||
|
|
||||||
|
**Independent Test**: Use a representative surface and verify (a) non-member receives not-found, and (b) member without capability receives forbidden and sees disabled UI with a helpful explanation.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a user who is not a member of the relevant tenant/workspace scope, **When** they attempt to access a related page or record, **Then** the response is not-found.
|
||||||
|
2. **Given** a user who is a member but lacks capability for a specific action, **When** they attempt to execute the action, **Then** the response is forbidden.
|
||||||
|
3. **Given** a member without capability viewing the UI, **When** they inspect the action surface, **Then** disallowed actions are visible-but-disabled with a clear explanation (not silently missing).
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A surface is intentionally read-only and has no meaningful bulk actions (must be explicitly exempted with a reason).
|
||||||
|
- A user loses membership/capability between page load and action execution (server-side enforcement must still apply).
|
||||||
|
- Bulk mutations above a risk threshold must require stronger confirmation (typed confirmation) to reduce accidental damage.
|
||||||
|
- Empty states that cannot be resolved from within the current surface (CTA should route users to the correct “next step” surface).
|
||||||
|
- Global search and cross-scope links must not reveal existence of tenant/workspace data to non-members.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature is primarily a UI governance and authorization-consistency feature. It MUST not introduce new external calls or long-running operations as part of the enforcement gate.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** This feature formalizes 404 vs 403 semantics and requires both UI signaling and server-side enforcement for in-scope actions.
|
||||||
|
|
||||||
|
**Constitution alignment (Action Surfaces):** This feature defines and enforces an Action Surface Contract, and introduces a mandatory UI Action Matrix for future admin UI-related specs.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001 (Contract profiles)**: The system MUST define a small set of action-surface profiles (e.g., CRUD, ReadOnly, RunLog, embedded relationship sub-lists) that describe minimum expected action areas for list and detail surfaces.
|
||||||
|
- **FR-002 (Required action slots)**: For each profile, the system MUST define required action slots for:
|
||||||
|
- list/table header actions,
|
||||||
|
- row actions,
|
||||||
|
- bulk actions,
|
||||||
|
- empty-state CTA(s),
|
||||||
|
- detail header actions,
|
||||||
|
- create/edit save/cancel conventions (where applicable).
|
||||||
|
- **FR-003 (Exemptions)**: The system MUST allow explicit exemptions for specific slots, and each exemption MUST include:
|
||||||
|
- a non-empty reason, and
|
||||||
|
- an optional tracking reference (issue/PR link or identifier).
|
||||||
|
- **FR-004 (Mandatory declaration)**: For every in-scope admin-console UI component that is new or modified, a declaration MUST exist that:
|
||||||
|
- identifies the profile,
|
||||||
|
- declares which slots are satisfied,
|
||||||
|
- lists any exemptions with reasons.
|
||||||
|
- **FR-005 (Automated gate)**: The system MUST automatically validate contract compliance for all in-scope UI components in the code review pipeline, and MUST fail with actionable messages when requirements are not met.
|
||||||
|
- **FR-006 (Consistency rules)**: The system MUST enforce the following UX conventions through (a) declaration validation for all in-scope surfaces and (b) runtime tests on representative surfaces:
|
||||||
|
- no more than two visible row actions (typically View/Edit),
|
||||||
|
- secondary actions grouped under the standard label “More”,
|
||||||
|
- bulk actions grouped under the standard label “More”,
|
||||||
|
- destructive actions are never primary.
|
||||||
|
- **FR-006a (Default safe bulk action)**: For ReadOnly and RunLog profiles, the default bulk action MUST be “Export” when the surface contains data; if “Export” is not offered, an explicit exemption with a reason is required.
|
||||||
|
- **FR-007 (Safety + confirmation)**: Any mutating or destructive action MUST require confirmation. For this feature, compliance is enforced via declaration checks plus representative runtime tests for touched surfaces.
|
||||||
|
- Mutation classes for FR-007:
|
||||||
|
- data mutation: create/update/delete/archive/restore/attach/detach,
|
||||||
|
- operation-start trigger: enqueue/dispatch/run/sync/verify/retry actions,
|
||||||
|
- access/safety mutation: role or membership changes, credential or permission updates, safety gate toggles.
|
||||||
|
- High-risk bulk actions MUST require typed confirmation when any of the following is true:
|
||||||
|
- the action is destructive/irreversible, or
|
||||||
|
- the action affects more than 25 records, or
|
||||||
|
- the action changes tenant/workspace access, credentials, or operational safety gates.
|
||||||
|
- **FR-008 (Auditability)**: All mutations and operation-start triggers MUST use existing audit sinks and MUST write an audit event that records at minimum: actor, scope (tenant/workspace), action type, target(s), timestamp, and outcome.
|
||||||
|
- Audit sinks for FR-008:
|
||||||
|
- tenant-scoped actions: `App\Services\Intune\AuditLogger` (writes `audit_logs`),
|
||||||
|
- workspace/platform-scoped actions: `App\Services\Audit\WorkspaceAuditLogger` (writes `audit_logs`).
|
||||||
|
- Operation-start triggers MAY satisfy FR-008 via canonical `OperationRun` creation when the action is run-oriented and existing architecture uses `OperationRun` as the observability record.
|
||||||
|
- For this feature, FR-008 is enforced as: no regressions on touched representative surfaces and no new ad-hoc audit sink introduced.
|
||||||
|
- **FR-009 (RBAC UI gating standard)**: UI gating MUST follow the project’s standard semantics:
|
||||||
|
- non-member → deny-as-not-found behavior,
|
||||||
|
- member without capability → deny-as-forbidden behavior,
|
||||||
|
- UI communicates “forbidden” as visible-but-disabled actions with a tooltip/explanation.
|
||||||
|
- **FR-010 (Server-side authorization)**: Server-side authorization MUST be enforced for every action execution (not just UI visibility) and MUST align with FR-009 semantics (see constitution RBAC-UX-001..004).
|
||||||
|
- **FR-011 (Canonical run links)**: Any “View run” deep link MUST use the canonical tenantless operations path as the primary link; tenant-scoped convenience links may exist but must not be primary.
|
||||||
|
- **FR-012 (Scope)**: The contract MUST cover both the tenant-scoped admin console and the platform/admin console surfaces for resources, pages, and embedded relationship sub-lists.
|
||||||
|
- **FR-012a (Explicit CI scope)**: The automated gate MUST validate Resources, Pages, and embedded relationship sub-lists in both tenant-scoped and platform/admin consoles, and MUST exclude Widgets from enforcement.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
|
||||||
|
- This feature does not redesign visual layout or page styling.
|
||||||
|
- This feature does not change domain workflows (sync/restore/verify), except to ensure action surfaces are complete or explicitly exempted.
|
||||||
|
- This feature does not change panel architecture or audience boundaries.
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- A safe, broadly applicable bulk action exists for read-only/run-log surfaces (“Export”) where data volume and permissions allow.
|
||||||
|
- Some existing surfaces will require temporary exemptions to keep the repository in a releasable state while retrofit work is completed.
|
||||||
|
|
||||||
|
### Enforcement Boundaries
|
||||||
|
|
||||||
|
- The CI validator for this feature is declaration-driven and deterministic (filesystem + reflection only).
|
||||||
|
- Runtime behavior is verified via representative tests for each profile and each critical rule family (grouping, confirmation, RBAC semantics, canonical run links).
|
||||||
|
- Deep runtime introspection of every Filament action in CI is explicitly out of scope for this iteration.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when admin UI components are changed)*
|
||||||
|
|
||||||
|
_Note: This matrix applies whenever admin UI components (resources, pages, or embedded relationship sub-lists) are added or changed._
|
||||||
|
|
||||||
|
This feature introduces a contract that applies broadly. The matrix below documents the minimum action surface expectations by profile.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| CRUD list + detail | Tenant panel + Admin panel | Primary: Create (if allowed). Optional: domain CTA (e.g., Sync/Run) | Primary: View, Edit (if allowed). Secondary: grouped under “More” | At least 1 bulk action (e.g., Archive/Restore/Export) | Primary CTA: Create or domain CTA | Primary: Edit (if allowed). Secondary grouped under “More” | Consistent Save + Cancel; no destructive primary | Yes (for mutations + operation-start) | Exempt bulk only with explicit reason (e.g., no safe bulk operation exists) |
|
||||||
|
| ReadOnly list + detail | Tenant panel + Admin panel | At least 1 CTA that provides value (e.g., Export/Refresh) | Primary: View. Secondary grouped under “More” | Bulk: Export (default) | CTA that resolves empty state (e.g., Refresh now) | “More” group for secondary actions | N/A | Maybe (typically for operation-start actions) | Exempt Export only with explicit reason (e.g., legal/security constraints) |
|
||||||
|
| RunLog list + detail | Tenant panel + Admin panel | Optional CTA routing to Operations hub | Primary: View | Bulk: Export (default) or Prune if retention exists; otherwise exempt with reason | Optional CTA routing to Operations hub | “View run” canonical link; optional Export | N/A | Maybe (for prune/retention changes) | “View run” must be canonical tenantless as primary deep link |
|
||||||
|
| Embedded relationship sub-list | Under parent detail | Header: Add/Attach/Create/Refresh depending on context | Primary: View and one context action (e.g., Detach/Remove). Secondary grouped under “More” | At least 1 bulk action or exemption with reason | CTA: next step (Add/Attach/Refresh) | N/A | N/A | Yes (for mutations) | Bulk may be exempted if relationship action is inherently single-record |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Action Surface Declaration**: A structured, human-reviewed statement of what actions a surface offers (and why some may be exempt).
|
||||||
|
- **Action Surface Profile**: A category of surfaces with shared minimum action expectations (CRUD, ReadOnly, RunLog, embedded relationship sub-list).
|
||||||
|
- **Action Slot**: A required location/type of action (header, row, bulk, empty-state, detail header, save/cancel).
|
||||||
|
- **Exemption**: A documented, explicit deviation from the contract for a specific slot.
|
||||||
|
- **Capability**: The permission concept used to decide whether an action is allowed.
|
||||||
|
- **Audit Event**: A record that an action was attempted/executed and its outcome.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- A single, canonical capability registry exists and is used consistently for authorization decisions.
|
||||||
|
- Standard UI gating helpers exist for tenant/workspace scopes and are the only allowed approach for UI enable/disable behavior.
|
||||||
|
- A centralized audit-event sink exists to record user-triggered mutations and operation starts.
|
||||||
|
|
||||||
|
### Acceptance Notes (verification approach)
|
||||||
|
|
||||||
|
- Contract compliance is verified via an automated validation gate that enumerates in-scope UI components and checks required slots/exemptions.
|
||||||
|
- Authorization correctness is verified via representative automated tests that prove not-found vs forbidden behavior across at least one surface per profile.
|
||||||
|
- Consistency/confirmation/canonical-link behavior is verified on representative runtime surfaces; declaration checks remain the repo-wide completeness gate.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001 (Coverage)**: 100% of in-scope UI components are contract-compliant or have explicit exemptions with reasons.
|
||||||
|
- **SC-002 (Regression prevention)**: Any new/modified in-scope UI component without a declaration fails validation in the review pipeline.
|
||||||
|
- **SC-003 (Action completeness)**: For all CRUD and ReadOnly list surfaces, the selection state always presents at least one bulk action or an explicit exemption.
|
||||||
|
- **SC-004 (Authorization correctness)**: At least one representative surface per profile has automated tests proving not-found vs forbidden behavior (non-member vs member without capability).
|
||||||
|
- **SC-005 (Developer experience)**: Validation failures provide actionable messages that identify the component and missing slot(s), enabling a fix without additional investigation.
|
||||||
127
specs/082-action-surface-contract/tasks.md
Normal file
127
specs/082-action-surface-contract/tasks.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for Spec 082 implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Action Surface Contract + CI Enforcement
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/082-action-surface-contract/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/, quickstart.md
|
||||||
|
**Tests**: Required (Pest) — this feature adds/changes CI enforcement.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Introduce the new support namespace and a guard-test entry point.
|
||||||
|
|
||||||
|
- [X] T001 Create ActionSurface namespaces in app/Support/Ui/ActionSurface/
|
||||||
|
- [X] T002 [P] Add guard test skeleton in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T003 [P] Add validator test skeleton in tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core contract model + discovery + validation that all user stories rely on.
|
||||||
|
|
||||||
|
- [X] T004 Create enums for component/profile/slot in app/Support/Ui/ActionSurface/Enums/ActionSurfaceComponentType.php
|
||||||
|
- [X] T005 [P] Create slot + exemption value objects in app/Support/Ui/ActionSurface/ActionSurfaceExemption.php
|
||||||
|
- [X] T006 [P] Create declaration + defaults DTOs in app/Support/Ui/ActionSurface/ActionSurfaceDeclaration.php
|
||||||
|
- [X] T007 Implement profile definitions (required slots per profile) in app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php
|
||||||
|
- [X] T008 Implement in-scope discovery (tenant discovery roots + admin explicit registrations/routes; exclude Widgets and System panel) in app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php
|
||||||
|
- [X] T009 Implement declaration contract validation (declare-or-exempt; reason required; More label; Export default) in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
|
||||||
|
- [X] T010 Add fast unit-style tests for validator rules in tests/Feature/Guards/ActionSurfaceValidatorTest.php (pure validation logic, no DB or Filament boot)
|
||||||
|
|
||||||
|
**Checkpoint**: Validator can enumerate + validate in-scope components deterministically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 — Prevent incomplete UI action surfaces (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: CI fails if an in-scope Filament component lacks a declaration (or an explicit exemption with a non-empty reason).
|
||||||
|
|
||||||
|
**Independent Test**: Add a new in-scope Filament component without a declaration; run the guard and confirm it fails with an actionable message; add a declaration/exemption and confirm it passes.
|
||||||
|
|
||||||
|
- [X] T011 [US1] Implement the CI guard that runs validation in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T012 [US1] Add baseline exemptions registry in app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php (reason required, explicit class allowlist with shrink-over-time rule)
|
||||||
|
- [X] T013 [US1] Wire exemptions into validator output in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
|
||||||
|
- [X] T014 [US1] Make guard output actionable (class + slot + hint) in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T015 [US1] Add tests proving widgets are excluded from scope in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T031 [US1] Add tests proving unknown classes are not auto-exempted and every exemption reason is non-empty in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
|
||||||
|
**Checkpoint**: Guard passes on main branch state, but blocks missing declarations going forward.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 — Consistent, enterprise-grade actions and empty-state guidance (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Provide consistent action-surface conventions (More grouping, Export default for RunLog/ReadOnly, empty-state CTA) and demonstrate them on representative surfaces.
|
||||||
|
|
||||||
|
**Independent Test**: Pick one CRUD-style list and one run-log style list; verify each has expected action areas (header/row/bulk/empty-state; detail header actions) and consistent ordering/grouping.
|
||||||
|
|
||||||
|
- [X] T016 [P] [US2] Define required slots for CRUD/ReadOnly/RunLog/RelationManager profiles in app/Support/Ui/ActionSurface/ActionSurfaceProfileDefinition.php
|
||||||
|
- [X] T017 [P] [US2] Enforce defaults: More label + Export expectation for ReadOnly/RunLog in app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
|
||||||
|
- [X] T018 [US2] Add a declaration to a representative CRUD surface in app/Filament/Resources/PolicyResource.php
|
||||||
|
- [X] T019 [US2] Ensure list empty-state CTA exists for the CRUD surface in app/Filament/Resources/PolicyResource/Pages/ListPolicies.php
|
||||||
|
- [X] T020 [US2] Add a declaration to a representative RunLog surface in app/Filament/Resources/OperationRunResource.php
|
||||||
|
- [X] T021 [US2] Ensure Export bulk action is present (or exempted with reason) for the RunLog surface in app/Filament/Resources/OperationRunResource.php
|
||||||
|
- [X] T022 [US2] Add a declaration to one embedded relationship sub-list in app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php
|
||||||
|
- [X] T023 [US2] Add tests asserting the representative declarations satisfy required slots in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T032 [US2] Add representative runtime test asserting PolicyResource row/bulk grouping uses “More” conventions in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T033 [US2] Add representative runtime test asserting canonical tenantless “View run” link usage in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T035 [US2] Add golden regression tests for “view-only lists use clickable rows without row actions” in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T036 [US2] Document action-surface contract rules (including “no lone View button; prefer clickable rows”) in docs/ui/action-surface-contract.md
|
||||||
|
|
||||||
|
**Checkpoint**: At least one CRUD + one RunLog + one embedded sub-list visibly follow the conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 — Predictable RBAC behavior for members vs non-members (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Validate consistent 404 vs 403 semantics and disabled-UI behavior for capability-gated actions.
|
||||||
|
|
||||||
|
**Independent Test**: Use a representative surface and verify (a) non-member receives not-found, and (b) member without capability receives forbidden and sees disabled UI with a helpful explanation.
|
||||||
|
|
||||||
|
- [X] T024 [P] [US3] Add non-member 404 route test on representative operations detail route in tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
|
||||||
|
- [X] T025 [US3] Add member-without-capability 403 server-side test by attempting to execute ListEntraGroupSyncRuns::sync_groups as a readonly member in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
|
||||||
|
- [X] T026 [US3] In that 403 test, assert no EntraGroupSyncJob is pushed and no EntraGroupSyncRun is created for the tenant in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
|
||||||
|
- [X] T027 [US3] Ensure disabled-UI semantics are covered for the same action (visible + disabled + standard tooltip) in tests/Feature/Filament/EntraGroupSyncRunResourceTest.php
|
||||||
|
- [X] T034 [US3] Add representative observability/audit regression assertion for an operation-start action (OperationRun record present with scope + actor) in tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
|
||||||
|
**Checkpoint**: One surface per profile has coverage for 404 vs 403 semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [X] T028 [P] Run formatter on changed files via vendor/bin/sail bin pint --dirty
|
||||||
|
- [X] T029 Run focused guard tests via vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- [X] T030 Run RBAC semantics test via vendor/bin/sail artisan test --compact tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- Setup (Phase 1) → Foundational (Phase 2) → US1 (Phase 3) → US2/US3 (Phases 4–5 can proceed in parallel) → Polish (Phase 6)
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- Phase 2: DTOs/enums (T004–T006) can be done in parallel.
|
||||||
|
- US2: Profile definition + validator rules (T016–T017) can be done in parallel.
|
||||||
|
- US3: Test scaffolding (T024) can be done in parallel with US2 retrofit tasks.
|
||||||
|
|
||||||
|
## Parallel Example: US2
|
||||||
|
|
||||||
|
- T018 (PolicyResource declaration) can be implemented in parallel with T020 (OperationRunResource declaration).
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (US1 only)
|
||||||
|
|
||||||
|
1. Complete Phase 1 + Phase 2.
|
||||||
|
2. Implement Phase 3 (US1) guard + exemptions.
|
||||||
|
3. Validate guard behavior by adding/removing a declaration locally.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- Add US2 representative surfaces next (PolicyResource + OperationRunResource + one RelationManager).
|
||||||
|
- Add US3 RBAC semantics tests last (keeps scope tight while contract infrastructure stabilizes).
|
||||||
@ -13,6 +13,7 @@
|
|||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -98,6 +99,41 @@
|
|||||||
->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
|
->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sync groups action is forbidden for readonly members when disabled check is bypassed', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListEntraGroupSyncRuns::class)->instance();
|
||||||
|
$action = $component->getAction([['name' => 'sync_groups']]);
|
||||||
|
expect($action)->not->toBeNull();
|
||||||
|
|
||||||
|
$thrown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$action->callBefore();
|
||||||
|
$action->call();
|
||||||
|
} catch (HttpException $exception) {
|
||||||
|
$thrown = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->not->toBeNull();
|
||||||
|
expect($thrown?->getStatusCode())->toBe(403);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
$runCount = EntraGroupSyncRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($runCount)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('sync groups action is disabled for readonly users with standard tooltip', function () {
|
test('sync groups action is disabled for readonly users with standard tooltip', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
|
|||||||
209
tests/Feature/Guards/ActionSurfaceContractTest.php
Normal file
209
tests/Feature/Guards/ActionSurfaceContractTest.php
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource\Pages\ListInventorySyncRuns;
|
||||||
|
use App\Filament\Resources\OperationRunResource;
|
||||||
|
use App\Filament\Resources\PolicyResource;
|
||||||
|
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||||
|
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||||
|
use App\Jobs\SyncPoliciesJob;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('passes the action surface contract guard for current repository state', function (): void {
|
||||||
|
$result = ActionSurfaceValidator::withBaselineExemptions()->validate();
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes widgets from action surface discovery scope', function (): void {
|
||||||
|
$classes = array_map(
|
||||||
|
static fn ($component): string => $component->className,
|
||||||
|
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$widgetClasses = array_values(array_filter($classes, static function (string $className): bool {
|
||||||
|
return str_starts_with($className, 'App\\Filament\\Widgets\\');
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect($widgetClasses)->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps baseline exemptions explicit and does not auto-exempt unknown classes', function (): void {
|
||||||
|
$exemptions = ActionSurfaceExemptions::baseline();
|
||||||
|
|
||||||
|
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
|
||||||
|
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
|
||||||
|
->keyBy('className');
|
||||||
|
|
||||||
|
$tenantResource = $components->get(\App\Filament\Resources\TenantResource::class);
|
||||||
|
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
|
||||||
|
|
||||||
|
expect($tenantResource)->not->toBeNull();
|
||||||
|
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
|
||||||
|
|
||||||
|
expect($policyResource)->not->toBeNull();
|
||||||
|
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::Tenant))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires non-empty reasons for every baseline exemption', function (): void {
|
||||||
|
$reasons = ActionSurfaceExemptions::baseline()->all();
|
||||||
|
|
||||||
|
foreach ($reasons as $className => $reason) {
|
||||||
|
expect(trim($reason))->not->toBe('', "Baseline exemption reason is empty for {$className}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ensures representative declarations satisfy required slots', function (): void {
|
||||||
|
$profiles = new ActionSurfaceProfileDefinition;
|
||||||
|
|
||||||
|
$declarations = [
|
||||||
|
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
|
||||||
|
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
|
||||||
|
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($declarations as $className => $declaration) {
|
||||||
|
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
|
||||||
|
expect($declaration->slot($slot))
|
||||||
|
->not->toBeNull("Missing required slot {$slot->value} in declaration for {$className}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$livewire = Livewire::test(ListPolicies::class)
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['sync']);
|
||||||
|
|
||||||
|
$table = $livewire->instance()->getTable();
|
||||||
|
|
||||||
|
$rowActions = $table->getActions();
|
||||||
|
$rowGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
|
||||||
|
|
||||||
|
expect($rowGroup)->toBeInstanceOf(ActionGroup::class);
|
||||||
|
expect($rowGroup?->getLabel())->toBe('More');
|
||||||
|
|
||||||
|
$primaryRowActionCount = collect($rowActions)
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
|
||||||
|
|
||||||
|
$bulkActions = $table->getBulkActions();
|
||||||
|
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
|
||||||
|
|
||||||
|
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class);
|
||||||
|
expect($bulkGroup?->getLabel())->toBe('More');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses canonical tenantless View run links on representative operation links', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(OperationRunLinks::view($run, $tenant))
|
||||||
|
->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$item = InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$livewire = Livewire::test(ListInventoryItems::class);
|
||||||
|
$table = $livewire->instance()->getTable();
|
||||||
|
|
||||||
|
expect($table->getActions())->toBeEmpty();
|
||||||
|
|
||||||
|
$recordUrl = $table->getRecordUrl($item);
|
||||||
|
|
||||||
|
expect($recordUrl)->not->toBeNull();
|
||||||
|
expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes lone View buttons and uses clickable rows on the inventory sync runs list', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = InventorySyncRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$livewire = Livewire::test(ListInventorySyncRuns::class);
|
||||||
|
$table = $livewire->instance()->getTable();
|
||||||
|
|
||||||
|
expect($table->getActions())->toBeEmpty();
|
||||||
|
|
||||||
|
$recordUrl = $table->getRecordUrl($run);
|
||||||
|
|
||||||
|
expect($recordUrl)->not->toBeNull();
|
||||||
|
expect($recordUrl)->toBe(InventorySyncRunResource::getUrl('view', ['record' => $run]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps representative operation-start actions observable with actor and scope metadata', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicies::class)
|
||||||
|
->mountAction('sync')
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
Queue::assertPushed(SyncPoliciesJob::class);
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('type', 'policy.sync')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect((int) $run?->tenant_id)->toBe((int) $tenant->getKey());
|
||||||
|
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
||||||
|
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
||||||
|
});
|
||||||
153
tests/Feature/Guards/ActionSurfaceValidatorTest.php
Normal file
153
tests/Feature/Guards/ActionSurfaceValidatorTest.php
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceDiscoveredComponent;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceSlotRequirement;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorCompleteStub
|
||||||
|
{
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorMissingSlotStub
|
||||||
|
{
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorRunLogNoExportStub
|
||||||
|
{
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||||
|
->withDefaults(new \App\Support\Ui\ActionSurface\ActionSurfaceDefaults(
|
||||||
|
moreGroupLabel: 'More',
|
||||||
|
exportIsDefaultBulkActionForReadOnly: false,
|
||||||
|
))
|
||||||
|
->satisfy(ActionSurfaceSlot::ListHeader)
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorExemptSlotWithoutReasonStub
|
||||||
|
{
|
||||||
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
|
{
|
||||||
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
|
->setSlot(ActionSurfaceSlot::ListHeader, ActionSurfaceSlotRequirement::exempt())
|
||||||
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ViewAction->value)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup)
|
||||||
|
->satisfy(ActionSurfaceSlot::ListEmptyState)
|
||||||
|
->satisfy(ActionSurfaceSlot::DetailHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class ActionSurfaceValidatorNoDeclarationStub {}
|
||||||
|
|
||||||
|
function actionSurfaceComponent(string $className): ActionSurfaceDiscoveredComponent
|
||||||
|
{
|
||||||
|
return new ActionSurfaceDiscoveredComponent(
|
||||||
|
className: $className,
|
||||||
|
componentType: ActionSurfaceComponentType::Resource,
|
||||||
|
panelScopes: [ActionSurfacePanelScope::Tenant],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('passes when all required slots are declared', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorCompleteStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when a required slot is missing', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorMissingSlotStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeTrue();
|
||||||
|
expect($result->formatForAssertion())->toContain('Required slot is not declared');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails missing declarations when no baseline exemption exists', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeTrue();
|
||||||
|
expect($result->formatForAssertion())->toContain('Missing action-surface declaration');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts missing declarations when explicit baseline exemption exists', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([
|
||||||
|
ActionSurfaceValidatorNoDeclarationStub::class => 'Retrofit intentionally deferred in validator unit test.',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorNoDeclarationStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a bulk exemption reason when run-log export default is disabled', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorRunLogNoExportStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeTrue();
|
||||||
|
expect($result->formatForAssertion())->toContain('ReadOnly/RunLog profile disables Export default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails when slot exemption reason is missing', function (): void {
|
||||||
|
$validator = new ActionSurfaceValidator(
|
||||||
|
profileDefinition: new ActionSurfaceProfileDefinition,
|
||||||
|
exemptions: new ActionSurfaceExemptions([]),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([actionSurfaceComponent(ActionSurfaceValidatorExemptSlotWithoutReasonStub::class)]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeTrue();
|
||||||
|
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
|
||||||
|
});
|
||||||
40
tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
Normal file
40
tests/Feature/Rbac/ActionSurfaceRbacSemanticsTest.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
it('returns 404 for non-members on representative action-surface route', function (): void {
|
||||||
|
$workspaceA = Workspace::factory()->create();
|
||||||
|
$workspaceB = Workspace::factory()->create();
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceA->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenantB = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$runB = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenantB->getKey(),
|
||||||
|
'workspace_id' => (int) $workspaceB->getKey(),
|
||||||
|
'type' => 'policy.sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspaceA->getKey()])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $runB->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user