feat: implement spec 192 record page header discipline (#226)
## Summary - implement Spec 192 across the targeted Filament record, detail, and edit pages with explicit action-surface inventory and guard coverage - add the focused Spec 192 browser smoke, feature tests, and spec artifacts under `specs/192-record-header-discipline` - improve unhandled promise rejection diagnostics by correlating 419s to the underlying Livewire request URL - disable panel-wide database notification polling on the admin, tenant, and system panels and cover the mitigation with focused tests ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/DatabaseNotificationsPollingTest.php tests/Feature/Filament/UnhandledRejectionLoggerAssetTest.php tests/Feature/Filament/FilamentNotificationsAssetsTest.php tests/Feature/Workspaces/ManagedTenantsLivewireUpdateTest.php tests/Feature/Filament/AdminSmokeTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - manual integrated-browser verification of the Spec 192 surfaces and the notification-polling mitigation ## Notes - Livewire v4 / Filament v5 compliance remains unchanged. - Provider registration stays in `bootstrap/providers.php`. - No Global Search behavior was expanded. - No destructive action confirmation semantics were relaxed. - The full test suite was not run in this PR. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #226
This commit is contained in:
parent
74210bac2e
commit
9f6985291e
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -169,6 +169,8 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
- PostgreSQL via existing `baseline_profiles`, `baseline_snapshots`, `baseline_snapshot_items`, `baseline_tenant_assignments`, `operation_runs`, and `findings` tables; no new persistence planned (190-baseline-compare-matrix)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns (191-baseline-compare-operator-mode)
|
||||||
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
- PostgreSQL via existing baseline, assignment, compare-run, and finding tables; no new persistence planned (191-baseline-compare-operator-mode)
|
||||||
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders (192-record-header-discipline)
|
||||||
|
- PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned (192-record-header-discipline)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -203,6 +205,7 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 192-record-header-discipline: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
|
||||||
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
|
- 191-baseline-compare-operator-mode: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `BaselineCompareMatrixBuilder`, `BadgeCatalog`, `CanonicalNavigationContext`, and `UiEnforcement` patterns
|
||||||
- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
|
- 190-baseline-compare-matrix: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `BaselineCompareService`, `BaselineSnapshotTruthResolver`, `BaselineCompareStats`, `RelatedNavigationResolver`, `CanonicalNavigationContext`, `BadgeCatalog`, and `UiEnforcement` patterns
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
@ -26,6 +26,9 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
@ -44,6 +47,7 @@
|
|||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -136,7 +140,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Edit leads and archive trails inside "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk mutations for baseline profiles in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'List defines empty-state create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page provides capture, compare-now, open-matrix, compare-assigned-tenants, and edit actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page keeps one state-sensitive primary action, moves snapshot and compare-matrix navigation into contextual related context, and groups secondary actions under "More".');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -319,6 +323,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (BaselineProfile $record): array => self::detailRelatedContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Metadata')
|
Section::make('Metadata')
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('createdByUser.name')
|
TextEntry::make('createdByUser.name')
|
||||||
@ -334,6 +347,37 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function detailRelatedContextEntries(BaselineProfile $record): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
$snapshotEntry = app(RelatedNavigationResolver::class)
|
||||||
|
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $record)[0] ?? null;
|
||||||
|
|
||||||
|
if ($snapshotEntry instanceof RelatedContextEntry) {
|
||||||
|
$entries[] = $snapshotEntry->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'compare_matrix',
|
||||||
|
label: 'Compare matrix',
|
||||||
|
value: 'Review compare matrix',
|
||||||
|
secondaryValue: $record->resolveCurrentConsumableSnapshot() instanceof BaselineSnapshot
|
||||||
|
? 'Use the latest consumable snapshot to inspect compare outcomes.'
|
||||||
|
: 'Open the matrix to inspect compare readiness and previous results.',
|
||||||
|
targetUrl: self::compareMatrixUrl($record),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'Open compare matrix',
|
||||||
|
contextBadge: 'Comparison',
|
||||||
|
)->toArray();
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$workspace = self::resolveWorkspace();
|
$workspace = self::resolveWorkspace();
|
||||||
|
|||||||
@ -16,15 +16,13 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
|
||||||
use App\Support\Navigation\RelatedContextEntry;
|
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Rbac\WorkspaceUiEnforcement;
|
use App\Support\Rbac\WorkspaceUiEnforcement;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -37,26 +35,19 @@ class ViewBaselineProfile extends ViewRecord
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('view_active_snapshot')
|
|
||||||
->label(fn (): string => $this->activeSnapshotEntry()?->actionLabel ?? 'View snapshot')
|
|
||||||
->url(fn (): ?string => $this->activeSnapshotEntry()?->targetUrl)
|
|
||||||
->hidden(fn (): bool => ! ($this->activeSnapshotEntry()?->isAvailable() ?? false))
|
|
||||||
->color('gray'),
|
|
||||||
$this->captureAction(),
|
$this->captureAction(),
|
||||||
$this->compareNowAction(),
|
$this->compareNowAction(),
|
||||||
$this->openCompareMatrixAction(),
|
ActionGroup::make([
|
||||||
$this->compareAssignedTenantsAction(),
|
$this->compareAssignedTenantsAction(),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(fn (): bool => $this->hasManageCapability()),
|
->visible(fn (): bool => $this->hasManageCapability()),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-m-ellipsis-vertical')
|
||||||
|
->color('gray'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function activeSnapshotEntry(): ?RelatedContextEntry
|
|
||||||
{
|
|
||||||
return app(RelatedNavigationResolver::class)
|
|
||||||
->headerEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_PROFILE, $this->getRecord())[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function captureAction(): Action
|
private function captureAction(): Action
|
||||||
{
|
{
|
||||||
/** @var BaselineProfile $profile */
|
/** @var BaselineProfile $profile */
|
||||||
@ -78,6 +69,7 @@ private function captureAction(): Action
|
|||||||
->label($label)
|
->label($label)
|
||||||
->icon('heroicon-o-camera')
|
->icon('heroicon-o-camera')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => $this->profileHasConsumableSnapshot())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading($label)
|
->modalHeading($label)
|
||||||
->modalDescription($modalDescription)
|
->modalDescription($modalDescription)
|
||||||
@ -190,6 +182,8 @@ private function compareNowAction(): Action
|
|||||||
return Action::make('compareNow')
|
return Action::make('compareNow')
|
||||||
->label($label)
|
->label($label)
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => ! $this->profileHasConsumableSnapshot())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading($label)
|
->modalHeading($label)
|
||||||
->modalDescription($modalDescription)
|
->modalDescription($modalDescription)
|
||||||
@ -309,15 +303,6 @@ private function compareNowAction(): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function openCompareMatrixAction(): Action
|
|
||||||
{
|
|
||||||
return Action::make('openCompareMatrix')
|
|
||||||
->label('Open compare matrix')
|
|
||||||
->icon('heroicon-o-squares-2x2')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): string => BaselineProfileResource::compareMatrixUrl($this->getRecord()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function compareAssignedTenantsAction(): Action
|
private function compareAssignedTenantsAction(): Action
|
||||||
{
|
{
|
||||||
$action = Action::make('compareAssignedTenants')
|
$action = Action::make('compareAssignedTenants')
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -115,7 +117,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes a Create snapshot CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Expire snapshot remains grouped under More.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Evidence snapshots do not support bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence and Expire snapshot actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes Refresh evidence as the primary action, keeps Expire snapshot visibly separated as danger, and renders operation/review-pack navigation in contextual related context.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -181,6 +183,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
TextEntry::make('summary.stale_dimensions')->label(static::evidenceCompletenessCountLabel(EvidenceCompletenessState::Stale->value))->placeholder('—'),
|
||||||
])
|
])
|
||||||
->columns(2),
|
->columns(2),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (EvidenceSnapshot $record): array => static::relatedContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Evidence dimensions')
|
Section::make('Evidence dimensions')
|
||||||
->schema([
|
->schema([
|
||||||
RepeatableEntry::make('items')
|
RepeatableEntry::make('items')
|
||||||
@ -213,6 +224,48 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function relatedContextEntries(EvidenceSnapshot $record): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if (is_numeric($record->operation_run_id)) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'operation_run',
|
||||||
|
label: 'Operation',
|
||||||
|
value: sprintf('#%d', (int) $record->operation_run_id),
|
||||||
|
secondaryValue: 'Open the latest evidence refresh operation.',
|
||||||
|
targetUrl: OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 10,
|
||||||
|
actionLabel: OperationRunLinks::openLabel(),
|
||||||
|
contextBadge: 'Operations',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pack = $record->reviewPacks()
|
||||||
|
->latest('created_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($pack instanceof \App\Models\ReviewPack && $pack->tenant instanceof Tenant) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'review_pack',
|
||||||
|
label: 'Review pack',
|
||||||
|
value: sprintf('#%d', (int) $pack->getKey()),
|
||||||
|
secondaryValue: 'Inspect the latest executive-pack output for this evidence basis.',
|
||||||
|
targetUrl: ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant),
|
||||||
|
targetKind: 'direct_record',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'View review pack',
|
||||||
|
contextBadge: 'Reporting',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
|||||||
@ -5,12 +5,9 @@
|
|||||||
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
namespace App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -29,30 +26,11 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->icon('heroicon-o-eye')
|
|
||||||
->color('gray')
|
|
||||||
->url(fn (): ?string => $this->record->operation_run_id ? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id) : null)
|
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id)),
|
|
||||||
Actions\Action::make('view_review_pack')
|
|
||||||
->label('View review pack')
|
|
||||||
->icon('heroicon-o-document-text')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$pack = $this->latestReviewPack();
|
|
||||||
|
|
||||||
if (! $pack instanceof ReviewPack || ! $pack->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $pack->tenant);
|
|
||||||
})
|
|
||||||
->hidden(fn (): bool => ! $this->latestReviewPack() instanceof ReviewPack),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('refresh_snapshot')
|
Actions\Action::make('refresh_snapshot')
|
||||||
->label('Refresh evidence')
|
->label('Refresh evidence')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -92,11 +70,4 @@ protected function getHeaderActions(): array
|
|||||||
->apply(),
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function latestReviewPack(): ?ReviewPack
|
|
||||||
{
|
|
||||||
return $this->record->reviewPacks()
|
|
||||||
->latest('created_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\FindingExceptionResource\Pages;
|
use App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\FindingExceptionEvidenceReference;
|
use App\Models\FindingExceptionEvidenceReference;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -20,6 +21,7 @@
|
|||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
@ -34,6 +36,7 @@
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Infolists\Components\RepeatableEntry;
|
use Filament\Infolists\Components\RepeatableEntry;
|
||||||
use Filament\Infolists\Components\TextEntry;
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
@ -115,7 +118,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'v1 keeps exception mutations direct and avoids a More menu.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Exception decisions require per-record review and intentionally omit bulk actions in v1.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state explains that new requests start from finding detail.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes linked finding navigation plus state-aware renewal and revocation actions.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail header exposes renewal and revocation only, while linked finding and approval-queue navigation move into contextual related context.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -217,6 +220,15 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(3),
|
->columns(3),
|
||||||
]),
|
]),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (FindingException $record): array => static::relatedContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
Section::make('Evidence references')
|
Section::make('Evidence references')
|
||||||
->schema([
|
->schema([
|
||||||
RepeatableEntry::make('evidenceReferences')
|
RepeatableEntry::make('evidenceReferences')
|
||||||
@ -245,6 +257,44 @@ public static function infolist(Schema $schema): Schema
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function relatedContextEntries(FindingException $record): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if ($record->finding && $record->tenant instanceof Tenant) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'finding',
|
||||||
|
label: 'Finding',
|
||||||
|
value: static::findingSummary($record),
|
||||||
|
secondaryValue: 'Return to the linked finding detail.',
|
||||||
|
targetUrl: FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant),
|
||||||
|
targetKind: 'direct_record',
|
||||||
|
priority: 10,
|
||||||
|
actionLabel: 'Open finding',
|
||||||
|
contextBadge: 'Governance',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->tenant instanceof Tenant && static::canAccessApprovalQueueForTenant($record->tenant)) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'approval_queue',
|
||||||
|
label: 'Approval queue',
|
||||||
|
value: 'Review pending exception requests',
|
||||||
|
secondaryValue: 'Return to the queue for the rest of this tenant’s governance workload.',
|
||||||
|
targetUrl: static::approvalQueueUrl($record->tenant),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'Open approval queue',
|
||||||
|
contextBadge: 'Queue',
|
||||||
|
)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
namespace App\Filament\Resources\FindingExceptionResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\FindingException;
|
use App\Models\FindingException;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -34,40 +33,10 @@ protected function resolveRecord(int|string $key): Model
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Action::make('open_finding')
|
|
||||||
->label('Open finding')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->url(function (): ?string {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
if (! $record instanceof FindingException || ! $record->finding || ! $record->tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FindingResource::getUrl('view', ['record' => $record->finding], panel: 'tenant', tenant: $record->tenant);
|
|
||||||
}),
|
|
||||||
Action::make('open_approval_queue')
|
|
||||||
->label('Open approval queue')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->color('gray')
|
|
||||||
->visible(function (): bool {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
return $record instanceof FindingException
|
|
||||||
&& FindingExceptionResource::canAccessApprovalQueueForTenant($record->tenant);
|
|
||||||
})
|
|
||||||
->url(function (): ?string {
|
|
||||||
$record = $this->getRecord();
|
|
||||||
|
|
||||||
return $record instanceof FindingException
|
|
||||||
? FindingExceptionResource::approvalQueueUrl($record->tenant)
|
|
||||||
: null;
|
|
||||||
}),
|
|
||||||
Action::make('renew_exception')
|
Action::make('renew_exception')
|
||||||
->label('Renew exception')
|
->label('Renew exception')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('primary')
|
||||||
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
->visible(fn (): bool => $this->canManageRecord() && $this->getRecord() instanceof FindingException && $this->getRecord()->canBeRenewed())
|
||||||
->fillForm(fn (): array => [
|
->fillForm(fn (): array => [
|
||||||
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
'owner_user_id' => $this->getRecord() instanceof FindingException ? $this->getRecord()->owner_user_id : null,
|
||||||
|
|||||||
@ -46,6 +46,8 @@
|
|||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use App\Support\Navigation\RelatedContextEntry;
|
||||||
|
use App\Support\Navigation\UnavailableRelationState;
|
||||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
||||||
use App\Support\Tenants\TenantActionDescriptor;
|
use App\Support\Tenants\TenantActionDescriptor;
|
||||||
use App\Support\Tenants\TenantActionSurface;
|
use App\Support\Tenants\TenantActionSurface;
|
||||||
@ -79,6 +81,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\HtmlString;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
@ -175,7 +178,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; overflow keeps helpers first, workflow actions next, and destructive actions last.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'At most one non-inspect row action stays primary; overflow keeps helpers first, workflow actions next, and destructive actions last.')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create action is reused in the list empty state.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view page exposes header actions via an action group.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Tenant view remains the workflow-heavy special type: pure navigation moves into contextual related content while header actions stay grouped into external-link, setup, and lifecycle buckets.');
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanManageAnyTenant(User $user): bool
|
private static function userCanManageAnyTenant(User $user): bool
|
||||||
@ -194,9 +197,12 @@ private static function userCanDeleteAnyTenant(User $user): bool
|
|||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
// ... [Schema Omitted - No Change] ...
|
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
|
Forms\Components\Placeholder::make('related_context')
|
||||||
|
->label('Related context')
|
||||||
|
->content(fn (?Tenant $record): HtmlString => static::tenantEditContextHtml($record))
|
||||||
|
->visible(fn (?Tenant $record): bool => $record instanceof Tenant && static::tenantEditContextEntries($record) !== []),
|
||||||
Forms\Components\TextInput::make('name')
|
Forms\Components\TextInput::make('name')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
@ -1990,6 +1996,16 @@ public static function infolist(Schema $schema): Schema
|
|||||||
])
|
])
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
|
Section::make('Related context')
|
||||||
|
->schema([
|
||||||
|
Infolists\Components\ViewEntry::make('related_context')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.related-context')
|
||||||
|
->state(fn (Tenant $record): array => static::tenantViewContextEntries($record))
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (Tenant $record): bool => static::tenantViewContextEntries($record) !== []),
|
||||||
Section::make('Provider')
|
Section::make('Provider')
|
||||||
->schema([
|
->schema([
|
||||||
Infolists\Components\ViewEntry::make('provider_connection_state')
|
Infolists\Components\ViewEntry::make('provider_connection_state')
|
||||||
@ -2236,6 +2252,166 @@ public static function relatedOnboardingDraftUrl(Tenant $tenant): ?string
|
|||||||
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
return route('admin.onboarding.draft', ['onboardingDraft' => (int) $draft->getKey()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function tenantViewContextEntries(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if (static::canEdit($tenant)) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'tenant_edit',
|
||||||
|
label: 'Tenant edit',
|
||||||
|
value: 'Edit tenant',
|
||||||
|
secondaryValue: 'Update tenant identity and lifecycle metadata.',
|
||||||
|
targetUrl: static::getUrl('edit', ['record' => $tenant]),
|
||||||
|
targetKind: 'direct_record',
|
||||||
|
priority: 10,
|
||||||
|
actionLabel: 'Edit',
|
||||||
|
contextBadge: 'Management',
|
||||||
|
);
|
||||||
|
} elseif (static::viewerCanInspectTenantContext($tenant)) {
|
||||||
|
$entries[] = RelatedContextEntry::unavailable(
|
||||||
|
key: 'tenant_edit',
|
||||||
|
label: 'Tenant edit',
|
||||||
|
state: new UnavailableRelationState(
|
||||||
|
relationKey: 'tenant_edit',
|
||||||
|
referenceValue: null,
|
||||||
|
reason: 'authorization_denied',
|
||||||
|
message: UiTooltips::insufficientPermission(),
|
||||||
|
),
|
||||||
|
targetKind: 'direct_record',
|
||||||
|
priority: 10,
|
||||||
|
actionLabel: 'Edit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static::viewerHasTenantCapability($tenant, Capabilities::PROVIDER_VIEW)) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'provider_connections',
|
||||||
|
label: 'Provider connections',
|
||||||
|
value: 'Open provider connections',
|
||||||
|
secondaryValue: 'Inspect consent, credentials, and health for this tenant.',
|
||||||
|
targetUrl: ProviderConnectionResource::getUrl('index', ['tenant_id' => $tenant->external_id], panel: 'admin'),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'Open',
|
||||||
|
contextBadge: 'Integrations',
|
||||||
|
);
|
||||||
|
} elseif (static::viewerCanInspectTenantContext($tenant)) {
|
||||||
|
$entries[] = RelatedContextEntry::unavailable(
|
||||||
|
key: 'provider_connections',
|
||||||
|
label: 'Provider connections',
|
||||||
|
state: new UnavailableRelationState(
|
||||||
|
relationKey: 'provider_connections',
|
||||||
|
referenceValue: null,
|
||||||
|
reason: 'authorization_denied',
|
||||||
|
message: UiTooltips::insufficientPermission(),
|
||||||
|
),
|
||||||
|
targetKind: 'canonical_page',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'Open',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$relatedOnboarding = static::relatedOnboardingDraftAction($tenant, TenantActionSurface::TenantViewHeader);
|
||||||
|
|
||||||
|
if ($relatedOnboarding instanceof TenantActionDescriptor && filled(static::relatedOnboardingDraftUrl($tenant))) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'related_onboarding',
|
||||||
|
label: 'Onboarding draft',
|
||||||
|
value: $relatedOnboarding->label,
|
||||||
|
secondaryValue: 'Return to the linked onboarding workflow for this tenant.',
|
||||||
|
targetUrl: (string) static::relatedOnboardingDraftUrl($tenant),
|
||||||
|
targetKind: 'workflow',
|
||||||
|
priority: 30,
|
||||||
|
actionLabel: 'Open',
|
||||||
|
contextBadge: 'Workflow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($entries)
|
||||||
|
->sortBy('priority')
|
||||||
|
->values()
|
||||||
|
->map(static fn (RelatedContextEntry $entry): array => $entry->toArray())
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function tenantEditContextEntries(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$entries = [
|
||||||
|
RelatedContextEntry::available(
|
||||||
|
key: 'tenant_view',
|
||||||
|
label: 'Tenant detail',
|
||||||
|
value: 'Open tenant detail',
|
||||||
|
secondaryValue: 'Review verification, RBAC, and lifecycle context without leaving the tenant resource.',
|
||||||
|
targetUrl: static::getUrl('view', ['record' => $tenant]),
|
||||||
|
targetKind: 'direct_record',
|
||||||
|
priority: 10,
|
||||||
|
actionLabel: 'Open',
|
||||||
|
contextBadge: 'Inspection',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
$relatedOnboarding = static::relatedOnboardingDraftAction($tenant, TenantActionSurface::TenantEditHeader);
|
||||||
|
|
||||||
|
if ($relatedOnboarding instanceof TenantActionDescriptor && filled(static::relatedOnboardingDraftUrl($tenant))) {
|
||||||
|
$entries[] = RelatedContextEntry::available(
|
||||||
|
key: 'related_onboarding',
|
||||||
|
label: 'Onboarding draft',
|
||||||
|
value: $relatedOnboarding->label,
|
||||||
|
secondaryValue: 'Return to the linked onboarding workflow for this tenant.',
|
||||||
|
targetUrl: (string) static::relatedOnboardingDraftUrl($tenant),
|
||||||
|
targetKind: 'workflow',
|
||||||
|
priority: 20,
|
||||||
|
actionLabel: 'Open',
|
||||||
|
contextBadge: 'Workflow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($entries)
|
||||||
|
->sortBy('priority')
|
||||||
|
->values()
|
||||||
|
->map(static fn (RelatedContextEntry $entry): array => $entry->toArray())
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantEditContextHtml(?Tenant $tenant): HtmlString
|
||||||
|
{
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return new HtmlString('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = static::tenantEditContextEntries($tenant);
|
||||||
|
|
||||||
|
if ($entries === []) {
|
||||||
|
return new HtmlString('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HtmlString((string) view('filament.infolists.entries.related-context', [
|
||||||
|
'entries' => $entries,
|
||||||
|
])->render());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantViewLifecycleGroupVisible(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return in_array(static::lifecycleActionDescriptor($tenant, TenantActionSurface::TenantViewHeader)?->key, ['archive', 'restore'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantViewExternalGroupVisible(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return static::adminConsentUrl($tenant) !== null || static::entraUrl($tenant) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tenantViewSetupGroupVisible(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
return $tenant->isActive();
|
||||||
|
}
|
||||||
|
|
||||||
public static function verificationActionVisible(Tenant $tenant): bool
|
public static function verificationActionVisible(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
$outcome = static::verificationReadinessOutcome($tenant);
|
$outcome = static::verificationReadinessOutcome($tenant);
|
||||||
@ -2284,6 +2460,28 @@ private static function tenantActionCatalogCacheKey(Tenant $tenant, TenantAction
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function viewerCanInspectTenantContext(Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user instanceof User && $user->canAccessTenant($tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function viewerHasTenantCapability(Tenant $tenant, string $capability): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var CapabilityResolver $resolver */
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return $resolver->isMember($user, $tenant)
|
||||||
|
&& $resolver->can($user, $tenant, $capability);
|
||||||
|
}
|
||||||
|
|
||||||
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
public static function archiveTenant(Tenant $record, WorkspaceAuditLogger $auditLogger): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|||||||
@ -18,13 +18,8 @@ class EditTenant extends EditRecord
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return array_values(array_filter([
|
||||||
Actions\ViewAction::make(),
|
Actions\ActionGroup::make([
|
||||||
Actions\Action::make('related_onboarding')
|
|
||||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantEditHeader) ?? 'View related onboarding')
|
|
||||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader)?->icon ?? 'heroicon-o-eye')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantEditHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Action::make('restore')
|
Action::make('restore')
|
||||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantEditHeader)?->label ?? 'Restore')
|
||||||
@ -61,6 +56,16 @@ protected function getHeaderActions(): array
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
];
|
])
|
||||||
|
->label('Lifecycle')
|
||||||
|
->icon('heroicon-o-archive-box')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
|
&& in_array(
|
||||||
|
TenantResource::lifecycleActionDescriptor($this->getRecord(), TenantActionSurface::TenantEditHeader)?->key,
|
||||||
|
['archive', 'restore'],
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ProviderConnectionResource;
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
use App\Filament\Widgets\Tenant\AdminRolesSummaryWidget;
|
||||||
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
use App\Filament\Widgets\Tenant\RecentOperationsSummary;
|
||||||
@ -56,29 +55,8 @@ protected function getHeaderWidgets(): array
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return array_values(array_filter([
|
||||||
Actions\ActionGroup::make([
|
Actions\ActionGroup::make([
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('provider_connections')
|
|
||||||
->label('Provider connections')
|
|
||||||
->icon('heroicon-o-link')
|
|
||||||
->url(fn (Tenant $record): string => ProviderConnectionResource::getUrl('index', ['tenant_id' => $record->external_id], panel: 'admin'))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::PROVIDER_VIEW)
|
|
||||||
->apply(),
|
|
||||||
UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('edit')
|
|
||||||
->label('Edit')
|
|
||||||
->icon('heroicon-o-pencil-square')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
|
||||||
->apply(),
|
|
||||||
Actions\Action::make('related_onboarding')
|
|
||||||
->label(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftActionLabel($record, TenantActionSurface::TenantViewHeader) ?? 'View related onboarding')
|
|
||||||
->icon(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader)?->icon ?? 'heroicon-o-eye')
|
|
||||||
->url(fn (Tenant $record): string => TenantResource::relatedOnboardingDraftUrl($record) ?? route('admin.onboarding'))
|
|
||||||
->visible(fn (Tenant $record): bool => TenantResource::relatedOnboardingDraftAction($record, TenantActionSurface::TenantViewHeader) instanceof \App\Support\Tenants\TenantActionDescriptor),
|
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Grant admin consent')
|
->label('Grant admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
@ -91,6 +69,13 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
->url(fn (Tenant $record) => TenantResource::entraUrl($record))
|
||||||
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null)
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
])
|
||||||
|
->label('External links')
|
||||||
|
->icon('heroicon-o-arrow-top-right-on-square')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
|
&& TenantResource::tenantViewExternalGroupVisible($this->getRecord())),
|
||||||
|
Actions\ActionGroup::make([
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('verify')
|
Actions\Action::make('verify')
|
||||||
->label(self::verificationHeaderActionLabel())
|
->label(self::verificationHeaderActionLabel())
|
||||||
@ -156,10 +141,6 @@ protected function getHeaderActions(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
if ($result->status === 'blocked') {
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
$actions = [
|
$actions = [
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label(OperationRunLinks::openLabel())
|
->label(OperationRunLinks::openLabel())
|
||||||
@ -283,6 +264,13 @@ protected function getHeaderActions(): array
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||||
->apply(),
|
->apply(),
|
||||||
|
])
|
||||||
|
->label('Setup')
|
||||||
|
->icon('heroicon-o-wrench-screwdriver')
|
||||||
|
->color('gray')
|
||||||
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
|
&& TenantResource::tenantViewSetupGroupVisible($this->getRecord())),
|
||||||
|
Actions\ActionGroup::make([
|
||||||
UiEnforcement::forAction(
|
UiEnforcement::forAction(
|
||||||
Actions\Action::make('restore')
|
Actions\Action::make('restore')
|
||||||
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
->label(fn (Tenant $record): string => TenantResource::lifecycleActionDescriptor($record, TenantActionSurface::TenantViewHeader)?->label ?? 'Restore')
|
||||||
@ -318,9 +306,11 @@ protected function getHeaderActions(): array
|
|||||||
->destructive()
|
->destructive()
|
||||||
->apply(),
|
->apply(),
|
||||||
])
|
])
|
||||||
->label('Actions')
|
->label('Lifecycle')
|
||||||
->icon('heroicon-o-ellipsis-vertical')
|
->icon('heroicon-o-archive-box')
|
||||||
->color('gray'),
|
->color('gray')
|
||||||
];
|
->visible(fn (): bool => $this->getRecord() instanceof Tenant
|
||||||
|
&& TenantResource::tenantViewLifecycleGroupVisible($this->getRecord())),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,7 +122,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes exactly one Create first review CTA.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Tenant reviews do not expose bulk actions in the first slice.')
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
|
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Export executive pack remains the only inline row shortcut.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes Refresh review, Publish review, Export executive pack, Archive review, and Create next review as applicable.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Detail exposes one dominant lifecycle action, groups the remaining lifecycle actions under "More", keeps archive in a danger bucket, and renders operation/export/evidence navigation in contextual summary content.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
@ -570,6 +570,7 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||||
|
'context_links' => static::summaryContextLinks($record),
|
||||||
'metrics' => [
|
'metrics' => [
|
||||||
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
|
||||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||||
@ -579,6 +580,43 @@ private static function summaryPresentation(TenantReview $record): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{title:string,label:string,url:string,description:string}>
|
||||||
|
*/
|
||||||
|
private static function summaryContextLinks(TenantReview $record): array
|
||||||
|
{
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
if (is_numeric($record->operation_run_id)) {
|
||||||
|
$links[] = [
|
||||||
|
'title' => 'Operation',
|
||||||
|
'label' => 'Open operation',
|
||||||
|
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
|
||||||
|
'description' => 'Inspect the latest review composition or refresh run.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->currentExportReviewPack && $record->tenant) {
|
||||||
|
$links[] = [
|
||||||
|
'title' => 'Executive pack',
|
||||||
|
'label' => 'View executive pack',
|
||||||
|
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
|
||||||
|
'description' => 'Open the current export that belongs to this review.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->evidenceSnapshot && $record->tenant) {
|
||||||
|
$links[] = [
|
||||||
|
'title' => 'Evidence snapshot',
|
||||||
|
'label' => 'View evidence snapshot',
|
||||||
|
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
|
||||||
|
'description' => 'Return to the evidence basis behind this review.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -11,7 +11,6 @@
|
|||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -53,35 +52,105 @@ protected function authorizeAccess(): void
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
$secondaryActions = $this->secondaryLifecycleActions();
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
return array_values(array_filter([
|
||||||
->icon('heroicon-o-eye')
|
$this->primaryLifecycleAction(),
|
||||||
|
Actions\ActionGroup::make($secondaryActions)
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-m-ellipsis-vertical')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->hidden(fn (): bool => ! is_numeric($this->record->operation_run_id))
|
->visible(fn (): bool => $secondaryActions !== []),
|
||||||
->url(fn (): ?string => $this->record->operation_run_id
|
Actions\ActionGroup::make([
|
||||||
? OperationRunLinks::tenantlessView((int) $this->record->operation_run_id)
|
$this->archiveReviewAction(),
|
||||||
: null),
|
])
|
||||||
Actions\Action::make('view_export')
|
->label('Danger')
|
||||||
->label('View executive pack')
|
->icon('heroicon-o-archive-box')
|
||||||
->icon('heroicon-o-document-arrow-down')
|
->color('danger')
|
||||||
->color('gray')
|
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
|
||||||
->hidden(fn (): bool => ! $this->record->currentExportReviewPack)
|
]));
|
||||||
->url(fn (): ?string => $this->record->currentExportReviewPack
|
}
|
||||||
? \App\Filament\Resources\ReviewPackResource::getUrl('view', ['record' => $this->record->currentExportReviewPack], tenant: $this->record->tenant)
|
|
||||||
: null),
|
private function primaryLifecycleAction(): ?Actions\Action
|
||||||
Actions\Action::make('view_evidence')
|
{
|
||||||
->label('View evidence snapshot')
|
return match ($this->primaryLifecycleActionName()) {
|
||||||
->icon('heroicon-o-shield-check')
|
'refresh_review' => $this->refreshReviewAction(),
|
||||||
->color('gray')
|
'publish_review' => $this->publishReviewAction(),
|
||||||
->hidden(fn (): bool => ! $this->record->evidenceSnapshot)
|
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||||
->url(fn (): ?string => $this->record->evidenceSnapshot
|
default => null,
|
||||||
? \App\Filament\Resources\EvidenceSnapshotResource::getUrl('view', ['record' => $this->record->evidenceSnapshot], tenant: $this->record->tenant)
|
};
|
||||||
: null),
|
}
|
||||||
UiEnforcement::forAction(
|
|
||||||
|
private function primaryLifecycleActionName(): ?string
|
||||||
|
{
|
||||||
|
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
||||||
|
return 'export_executive_pack';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) $this->record->status === TenantReviewStatus::Ready->value) {
|
||||||
|
return 'publish_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->record->isMutable()) {
|
||||||
|
return 'refresh_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Actions\Action>
|
||||||
|
*/
|
||||||
|
private function secondaryLifecycleActions(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(array_map(
|
||||||
|
fn (string $name): ?Actions\Action => match ($name) {
|
||||||
|
'refresh_review' => $this->refreshReviewAction(),
|
||||||
|
'publish_review' => $this->publishReviewAction(),
|
||||||
|
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||||
|
'create_next_review' => $this->createNextReviewAction(),
|
||||||
|
default => null,
|
||||||
|
},
|
||||||
|
$this->secondaryLifecycleActionNames(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function secondaryLifecycleActionNames(): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
if ($this->record->isMutable()) {
|
||||||
|
$names[] = 'refresh_review';
|
||||||
|
$names[] = 'publish_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array((string) $this->record->status, [
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
], true)) {
|
||||||
|
$names[] = 'export_executive_pack';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->record->isPublished()) {
|
||||||
|
$names[] = 'create_next_review';
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
$names,
|
||||||
|
fn (string $name): bool => $name !== $this->primaryLifecycleActionName(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('refresh_review')
|
Actions\Action::make('refresh_review')
|
||||||
->label('Refresh review')
|
->label('Refresh review')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
@ -104,11 +173,16 @@ protected function getHeaderActions(): array
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply();
|
||||||
UiEnforcement::forAction(
|
}
|
||||||
|
|
||||||
|
private function publishReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('publish_review')
|
Actions\Action::make('publish_review')
|
||||||
->label('Publish review')
|
->label('Publish review')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
|
->color('primary')
|
||||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
@ -132,12 +206,17 @@ protected function getHeaderActions(): array
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply();
|
||||||
UiEnforcement::forAction(
|
}
|
||||||
|
|
||||||
|
private function exportExecutivePackAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
->hidden(fn (): bool => ! in_array($this->record->status, [
|
->color('primary')
|
||||||
|
->hidden(fn (): bool => ! in_array((string) $this->record->status, [
|
||||||
TenantReviewStatus::Ready->value,
|
TenantReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
TenantReviewStatus::Published->value,
|
||||||
], true))
|
], true))
|
||||||
@ -145,9 +224,12 @@ protected function getHeaderActions(): array
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply();
|
||||||
Actions\ActionGroup::make([
|
}
|
||||||
UiEnforcement::forAction(
|
|
||||||
|
private function createNextReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('create_next_review')
|
Actions\Action::make('create_next_review')
|
||||||
->label('Create next review')
|
->label('Create next review')
|
||||||
->icon('heroicon-o-document-duplicate')
|
->icon('heroicon-o-document-duplicate')
|
||||||
@ -172,8 +254,12 @@ protected function getHeaderActions(): array
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply();
|
||||||
UiEnforcement::forAction(
|
}
|
||||||
|
|
||||||
|
private function archiveReviewAction(): Actions\Action
|
||||||
|
{
|
||||||
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('archive_review')
|
Actions\Action::make('archive_review')
|
||||||
->label('Archive review')
|
->label('Archive review')
|
||||||
->icon('heroicon-o-archive-box')
|
->icon('heroicon-o-archive-box')
|
||||||
@ -195,11 +281,6 @@ protected function getHeaderActions(): array
|
|||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply(),
|
->apply();
|
||||||
])
|
|
||||||
->label('More')
|
|
||||||
->icon('heroicon-m-ellipsis-vertical')
|
|
||||||
->color('gray'),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -184,7 +184,7 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
->databaseNotificationsPolling(null)
|
||||||
->unsavedChangesAlerts()
|
->unsavedChangesAlerts()
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
|
|||||||
@ -37,7 +37,7 @@ public function panel(Panel $panel): Panel
|
|||||||
'primary' => Color::Blue,
|
'primary' => Color::Blue,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
->databaseNotificationsPolling(null)
|
||||||
->renderHook(
|
->renderHook(
|
||||||
PanelsRenderHook::BODY_START,
|
PanelsRenderHook::BODY_START,
|
||||||
fn () => view('filament.system.components.break-glass-banner')->render(),
|
fn () => view('filament.system.components.break-glass-banner')->render(),
|
||||||
|
|||||||
@ -95,7 +95,7 @@ public function panel(Panel $panel): Panel
|
|||||||
FilamentInfoWidget::class,
|
FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
->databaseNotifications()
|
->databaseNotifications()
|
||||||
->databaseNotificationsPolling('30s')
|
->databaseNotificationsPolling(null)
|
||||||
->middleware([
|
->middleware([
|
||||||
EncryptCookies::class,
|
EncryptCookies::class,
|
||||||
AddQueuedCookiesToResponse::class,
|
AddQueuedCookiesToResponse::class,
|
||||||
|
|||||||
@ -4,6 +4,20 @@
|
|||||||
|
|
||||||
namespace App\Support\Ui\ActionSurface;
|
namespace App\Support\Ui\ActionSurface;
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination;
|
||||||
|
use App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet;
|
||||||
|
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||||
|
use App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
|
||||||
|
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
|
||||||
|
use App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion;
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection;
|
||||||
|
use App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||||
|
use App\Filament\Resources\Workspaces\Pages\ViewWorkspace;
|
||||||
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
|
||||||
|
|
||||||
final class ActionSurfaceExemptions
|
final class ActionSurfaceExemptions
|
||||||
@ -49,4 +63,275 @@ public function hasClass(string $className): bool
|
|||||||
{
|
{
|
||||||
return array_key_exists($className, $this->componentReasons);
|
return array_key_exists($className, $this->componentReasons);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* classification: string,
|
||||||
|
* canonicalNoun: string,
|
||||||
|
* panelScope: string,
|
||||||
|
* ownerScope: string,
|
||||||
|
* routeKind: string,
|
||||||
|
* requiresHeaderRemediation: bool,
|
||||||
|
* exceptionReason: ?string,
|
||||||
|
* maxVisiblePrimaryActions: int,
|
||||||
|
* allowsNoPrimaryAction: bool,
|
||||||
|
* requiresGroupedSecondaryActions: bool,
|
||||||
|
* requiresDangerSeparation: bool,
|
||||||
|
* allowsPrimaryNavigation: bool,
|
||||||
|
* browserSmokeRequired: bool
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public static function spec192RecordPageInventory(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ViewBaselineProfile::class => [
|
||||||
|
'surfaceKey' => 'baseline_profile_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Baseline profile',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => false,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewEvidenceSnapshot::class => [
|
||||||
|
'surfaceKey' => 'evidence_snapshot_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Evidence snapshot',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => false,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewFindingException::class => [
|
||||||
|
'surfaceKey' => 'finding_exception_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Finding exception',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewTenantReview::class => [
|
||||||
|
'surfaceKey' => 'tenant_review_view',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Tenant review',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => false,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
EditTenant::class => [
|
||||||
|
'surfaceKey' => 'tenant_edit',
|
||||||
|
'classification' => 'remediation_required',
|
||||||
|
'canonicalNoun' => 'Tenant',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'edit',
|
||||||
|
'requiresHeaderRemediation' => true,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewTenant::class => [
|
||||||
|
'surfaceKey' => 'tenant_view',
|
||||||
|
'classification' => 'workflow_heavy_special_type',
|
||||||
|
'canonicalNoun' => 'Tenant',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => 'Tenant detail remains a workflow-heavy hub for external links, verification/setup, and lifecycle operations. It may show one dominant next step, but it must never silently fall back to a flat multi-button strip.',
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => false,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewProviderConnection::class => [
|
||||||
|
'surfaceKey' => 'provider_connection_view',
|
||||||
|
'classification' => 'minor_alignment_only',
|
||||||
|
'canonicalNoun' => 'Provider connection',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => false,
|
||||||
|
],
|
||||||
|
ViewFinding::class => [
|
||||||
|
'surfaceKey' => 'finding_view',
|
||||||
|
'classification' => 'minor_alignment_only',
|
||||||
|
'canonicalNoun' => 'Finding',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => false,
|
||||||
|
],
|
||||||
|
ViewReviewPack::class => [
|
||||||
|
'surfaceKey' => 'review_pack_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Review pack',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewAlertDestination::class => [
|
||||||
|
'surfaceKey' => 'alert_destination_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Alert destination',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewPolicyVersion::class => [
|
||||||
|
'surfaceKey' => 'policy_version_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Policy version',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewWorkspace::class => [
|
||||||
|
'surfaceKey' => 'workspace_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Workspace',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewBaselineSnapshot::class => [
|
||||||
|
'surfaceKey' => 'baseline_snapshot_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Baseline snapshot',
|
||||||
|
'panelScope' => 'admin',
|
||||||
|
'ownerScope' => 'workspace-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => false,
|
||||||
|
'requiresDangerSeparation' => false,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
ViewBackupSet::class => [
|
||||||
|
'surfaceKey' => 'backup_set_view',
|
||||||
|
'classification' => 'compliant_reference',
|
||||||
|
'canonicalNoun' => 'Backup set',
|
||||||
|
'panelScope' => 'tenant',
|
||||||
|
'ownerScope' => 'tenant-owned',
|
||||||
|
'routeKind' => 'view',
|
||||||
|
'requiresHeaderRemediation' => false,
|
||||||
|
'exceptionReason' => null,
|
||||||
|
'maxVisiblePrimaryActions' => 1,
|
||||||
|
'allowsNoPrimaryAction' => true,
|
||||||
|
'requiresGroupedSecondaryActions' => true,
|
||||||
|
'requiresDangerSeparation' => true,
|
||||||
|
'allowsPrimaryNavigation' => true,
|
||||||
|
'browserSmokeRequired' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* surfaceKey: string,
|
||||||
|
* classification: string,
|
||||||
|
* canonicalNoun: string,
|
||||||
|
* panelScope: string,
|
||||||
|
* ownerScope: string,
|
||||||
|
* routeKind: string,
|
||||||
|
* requiresHeaderRemediation: bool,
|
||||||
|
* exceptionReason: ?string,
|
||||||
|
* maxVisiblePrimaryActions: int,
|
||||||
|
* allowsNoPrimaryAction: bool,
|
||||||
|
* requiresGroupedSecondaryActions: bool,
|
||||||
|
* requiresDangerSeparation: bool,
|
||||||
|
* allowsPrimaryNavigation: bool,
|
||||||
|
* browserSmokeRequired: bool
|
||||||
|
* }|null
|
||||||
|
*/
|
||||||
|
public static function spec192RecordPageSurface(string $className): ?array
|
||||||
|
{
|
||||||
|
return self::spec192RecordPageInventory()[$className] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,8 @@ public function validateComponents(array $components): ActionSurfaceValidationRe
|
|||||||
{
|
{
|
||||||
$issues = [];
|
$issues = [];
|
||||||
|
|
||||||
|
$this->validateSpec192RecordPageInventory($issues);
|
||||||
|
|
||||||
foreach ($components as $component) {
|
foreach ($components as $component) {
|
||||||
if (! class_exists($component->className)) {
|
if (! class_exists($component->className)) {
|
||||||
$issues[] = new ActionSurfaceValidationIssue(
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
@ -106,6 +108,128 @@ className: $component->className,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
|
*/
|
||||||
|
private function validateSpec192RecordPageInventory(array &$issues): void
|
||||||
|
{
|
||||||
|
$allowedClassifications = [
|
||||||
|
'remediation_required',
|
||||||
|
'minor_alignment_only',
|
||||||
|
'compliant_reference',
|
||||||
|
'workflow_heavy_special_type',
|
||||||
|
];
|
||||||
|
$allowedPanelScopes = ['admin', 'tenant'];
|
||||||
|
$allowedOwnerScopes = ['workspace-owned', 'tenant-owned'];
|
||||||
|
$allowedRouteKinds = ['view', 'edit'];
|
||||||
|
$surfaceKeys = [];
|
||||||
|
|
||||||
|
foreach (ActionSurfaceExemptions::spec192RecordPageInventory() as $className => $surface) {
|
||||||
|
if (! class_exists($className)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 inventory references a page class that does not exist.',
|
||||||
|
hint: 'Keep ActionSurfaceExemptions::spec192RecordPageInventory() aligned with the in-scope page classes.',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$surfaceKey = (string) ($surface['surfaceKey'] ?? '');
|
||||||
|
|
||||||
|
if ($surfaceKey === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 inventory entry is missing a non-empty surface key.',
|
||||||
|
hint: 'Provide the stable spec surface key for this page.',
|
||||||
|
);
|
||||||
|
} elseif (isset($surfaceKeys[$surfaceKey])) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: sprintf('Spec 192 surface key "%s" is declared more than once.', $surfaceKey),
|
||||||
|
hint: 'Each in-scope page must have a unique surface key.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$surfaceKeys[$surfaceKey] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['classification'] ?? null, $allowedClassifications, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 classification is invalid or missing.',
|
||||||
|
hint: 'Use remediation_required, minor_alignment_only, compliant_reference, or workflow_heavy_special_type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['panelScope'] ?? null, $allowedPanelScopes, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 panel scope is invalid or missing.',
|
||||||
|
hint: 'Use the concrete panel scope for the record page inventory entry.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['ownerScope'] ?? null, $allowedOwnerScopes, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 owner scope is invalid or missing.',
|
||||||
|
hint: 'Use workspace-owned or tenant-owned.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($surface['routeKind'] ?? null, $allowedRouteKinds, true)) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 route kind is invalid or missing.',
|
||||||
|
hint: 'Use view or edit.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($surface['canonicalNoun'] ?? null) || trim((string) $surface['canonicalNoun']) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 canonical noun must be non-empty.',
|
||||||
|
hint: 'Use the stable operator-facing noun for the surface.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$classification = (string) ($surface['classification'] ?? '');
|
||||||
|
$exceptionReason = $surface['exceptionReason'] ?? null;
|
||||||
|
|
||||||
|
if ($classification === 'workflow_heavy_special_type') {
|
||||||
|
if (! is_string($exceptionReason) || trim($exceptionReason) === '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Workflow-heavy Spec 192 pages require an explicit exception reason.',
|
||||||
|
hint: 'Document why this surface is intentionally exempt from the standard record-page rule.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} elseif ($exceptionReason !== null && trim((string) $exceptionReason) !== '') {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Only workflow-heavy Spec 192 pages may carry an exception reason.',
|
||||||
|
hint: 'Clear the exception reason for standard, minor-alignment, and compliant-reference surfaces.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($surface['maxVisiblePrimaryActions'] ?? null) !== 1) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Spec 192 maxVisiblePrimaryActions must stay pinned to 1.',
|
||||||
|
hint: 'The bounded header contract allows at most one visible primary header action.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($classification === 'remediation_required' && ($surface['allowsPrimaryNavigation'] ?? false) === true) {
|
||||||
|
$issues[] = new ActionSurfaceValidationIssue(
|
||||||
|
className: $className,
|
||||||
|
message: 'Remediation-required Spec 192 surfaces must not allow primary navigation.',
|
||||||
|
hint: 'Move pure navigation into contextual placement outside the header.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, ActionSurfaceValidationIssue> $issues
|
* @param array<int, ActionSurfaceValidationIssue> $issues
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
||||||
|
|
||||||
const recentKeys = new Map();
|
const recentKeys = new Map();
|
||||||
|
const recentTransportFailures = [];
|
||||||
|
|
||||||
const cleanupRecentKeys = (nowMs) => {
|
const cleanupRecentKeys = (nowMs) => {
|
||||||
for (const [key, timestampMs] of recentKeys.entries()) {
|
for (const [key, timestampMs] of recentKeys.entries()) {
|
||||||
@ -19,6 +20,215 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cleanupRecentTransportFailures = (nowMs) => {
|
||||||
|
while (recentTransportFailures.length > 0 && nowMs - recentTransportFailures[0].timestampMs > 15_000) {
|
||||||
|
recentTransportFailures.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUrl = (value) => {
|
||||||
|
if (typeof value !== 'string' || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(value, window.location.href).href;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBodySnippet = (body) => {
|
||||||
|
if (typeof body !== 'string' || body === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.slice(0, 1_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordTransportFailure = ({ requestUrl, method, status, body, transportType }) => {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
cleanupRecentTransportFailures(nowMs);
|
||||||
|
|
||||||
|
recentTransportFailures.push({
|
||||||
|
requestUrl: normalizeUrl(requestUrl),
|
||||||
|
method: typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET',
|
||||||
|
status: Number.isFinite(status) ? status : null,
|
||||||
|
bodySnippet: toBodySnippet(body),
|
||||||
|
transportType: typeof transportType === 'string' && transportType !== '' ? transportType : 'unknown',
|
||||||
|
timestampMs: nowMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (recentTransportFailures.length > 30) {
|
||||||
|
recentTransportFailures.shift();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTransportMetadata = (reason) => {
|
||||||
|
if (reason === null || typeof reason !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directRequestUrl = typeof reason.requestUrl === 'string'
|
||||||
|
? normalizeUrl(reason.requestUrl)
|
||||||
|
: (typeof reason.url === 'string' ? normalizeUrl(reason.url) : null);
|
||||||
|
|
||||||
|
if (directRequestUrl) {
|
||||||
|
return {
|
||||||
|
requestUrl: directRequestUrl,
|
||||||
|
method: typeof reason.method === 'string' ? reason.method.toUpperCase() : null,
|
||||||
|
transportType: 'reason',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTransportEnvelope(reason)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const reasonBodySnippet = toBodySnippet(reason.body);
|
||||||
|
|
||||||
|
cleanupRecentTransportFailures(nowMs);
|
||||||
|
|
||||||
|
for (let index = recentTransportFailures.length - 1; index >= 0; index -= 1) {
|
||||||
|
const candidate = recentTransportFailures[index];
|
||||||
|
|
||||||
|
if (nowMs - candidate.timestampMs > 5_000) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.status !== reason.status) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reasonBodySnippet !== null && candidate.bodySnippet !== null && candidate.bodySnippet !== reasonBodySnippet) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestUrl: candidate.requestUrl,
|
||||||
|
method: candidate.method,
|
||||||
|
transportType: candidate.transportType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractRequestMetadata = (input, init) => {
|
||||||
|
if (input instanceof Request) {
|
||||||
|
return {
|
||||||
|
requestUrl: normalizeUrl(input.url),
|
||||||
|
method: typeof input.method === 'string' && input.method !== ''
|
||||||
|
? input.method.toUpperCase()
|
||||||
|
: 'GET',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestUrl: normalizeUrl(typeof input === 'string' ? input : String(input ?? '')),
|
||||||
|
method: typeof init?.method === 'string' && init.method !== ''
|
||||||
|
? init.method.toUpperCase()
|
||||||
|
: 'GET',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window.fetch === 'function' && !window.__tenantpilotUnhandledRejectionFetchInstrumented) {
|
||||||
|
const originalFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
|
window.fetch = async (...args) => {
|
||||||
|
const [input, init] = args;
|
||||||
|
const transport = extractRequestMetadata(input, init);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await originalFetch(...args);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const clonedResponse = typeof response.clone === 'function' ? response.clone() : null;
|
||||||
|
|
||||||
|
if (clonedResponse && typeof clonedResponse.text === 'function') {
|
||||||
|
clonedResponse.text()
|
||||||
|
.then((body) => {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: response.status,
|
||||||
|
body,
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: response.status,
|
||||||
|
body: null,
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: response.status,
|
||||||
|
body: null,
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: transport.requestUrl,
|
||||||
|
method: transport.method,
|
||||||
|
status: null,
|
||||||
|
body: error instanceof Error ? error.message : String(error ?? ''),
|
||||||
|
transportType: 'fetch',
|
||||||
|
});
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__tenantpilotUnhandledRejectionFetchInstrumented = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof XMLHttpRequest !== 'undefined' && !window.__tenantpilotUnhandledRejectionXhrInstrumented) {
|
||||||
|
const originalOpen = XMLHttpRequest.prototype.open;
|
||||||
|
const originalSend = XMLHttpRequest.prototype.send;
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||||||
|
this.__tenantpilotRequestMethod = typeof method === 'string' && method !== '' ? method.toUpperCase() : 'GET';
|
||||||
|
this.__tenantpilotRequestUrl = normalizeUrl(typeof url === 'string' ? url : String(url ?? ''));
|
||||||
|
|
||||||
|
return originalOpen.call(this, method, url, ...rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
XMLHttpRequest.prototype.send = function (...args) {
|
||||||
|
if (!this.__tenantpilotTransportFailureListenerApplied) {
|
||||||
|
this.addEventListener('loadend', () => {
|
||||||
|
if (typeof this.status === 'number' && this.status >= 400) {
|
||||||
|
recordTransportFailure({
|
||||||
|
requestUrl: this.__tenantpilotRequestUrl,
|
||||||
|
method: this.__tenantpilotRequestMethod,
|
||||||
|
status: this.status,
|
||||||
|
body: typeof this.responseText === 'string' ? this.responseText : null,
|
||||||
|
transportType: 'xhr',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.__tenantpilotTransportFailureListenerApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalSend.apply(this, args);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__tenantpilotUnhandledRejectionXhrInstrumented = true;
|
||||||
|
}
|
||||||
|
|
||||||
const isTransportEnvelope = (value) => {
|
const isTransportEnvelope = (value) => {
|
||||||
return value !== null
|
return value !== null
|
||||||
&& typeof value === 'object'
|
&& typeof value === 'object'
|
||||||
@ -101,6 +311,9 @@
|
|||||||
'errors',
|
'errors',
|
||||||
'reason',
|
'reason',
|
||||||
'code',
|
'code',
|
||||||
|
'url',
|
||||||
|
'requestUrl',
|
||||||
|
'method',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const key of allowedKeys) {
|
for (const key of allowedKeys) {
|
||||||
@ -139,10 +352,14 @@
|
|||||||
|
|
||||||
window.addEventListener('unhandledrejection', (event) => {
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
const normalizedReason = normalizeReason(event.reason);
|
const normalizedReason = normalizeReason(event.reason);
|
||||||
|
const transport = resolveTransportMetadata(normalizedReason);
|
||||||
const payload = {
|
const payload = {
|
||||||
source: 'window.unhandledrejection',
|
source: 'window.unhandledrejection',
|
||||||
href: window.location.href,
|
href: window.location.href,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
requestUrl: transport?.requestUrl ?? null,
|
||||||
|
requestMethod: transport?.method ?? null,
|
||||||
|
transportType: transport?.transportType ?? null,
|
||||||
reason: normalizedReason,
|
reason: normalizedReason,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -155,6 +372,7 @@
|
|||||||
const dedupeKey = toStableJson({
|
const dedupeKey = toStableJson({
|
||||||
source: payload.source,
|
source: payload.source,
|
||||||
href: payload.href,
|
href: payload.href,
|
||||||
|
requestUrl: payload.requestUrl,
|
||||||
reason: payload.reason,
|
reason: payload.reason,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
|
$metrics = is_array($state['metrics'] ?? null) ? $state['metrics'] : [];
|
||||||
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
$highlights = is_array($state['highlights'] ?? null) ? $state['highlights'] : [];
|
||||||
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
$nextActions = is_array($state['next_actions'] ?? null) ? $state['next_actions'] : [];
|
||||||
|
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
|
||||||
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
|
||||||
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
|
||||||
@endphp
|
@endphp
|
||||||
@ -72,6 +73,37 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if ($contextLinks !== [])
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
@foreach ($contextLinks as $link)
|
||||||
|
@php
|
||||||
|
$title = is_string($link['title'] ?? null) ? $link['title'] : null;
|
||||||
|
$label = is_string($link['label'] ?? null) ? $link['label'] : null;
|
||||||
|
$url = is_string($link['url'] ?? null) ? $link['url'] : null;
|
||||||
|
$description = is_string($link['description'] ?? null) ? $link['description'] : null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@continue($title === null || $label === null || $url === null)
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
|
||||||
|
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $title }}</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<x-filament::link :href="$url" icon="heroicon-m-arrow-top-right-on-square">
|
||||||
|
{{ $label }}
|
||||||
|
</x-filament::link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($description !== null && trim($description) !== '')
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $description }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,300 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\AlertDestinationResource;
|
||||||
|
use App\Filament\Resources\BackupSetResource;
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Filament\Resources\BaselineSnapshotResource;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Filament\Resources\Workspaces\WorkspaceResource;
|
||||||
|
use App\Models\AlertDestination;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
pest()->browser()->timeout(20_000);
|
||||||
|
|
||||||
|
function spec192ApprovedFindingException(Tenant $tenant, User $requester)
|
||||||
|
{
|
||||||
|
$approver = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var FindingExceptionService $service */
|
||||||
|
$service = app(FindingExceptionService::class);
|
||||||
|
|
||||||
|
$requested = $service->request($finding, $tenant, $requester, [
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'request_reason' => 'Browser smoke test exception request.',
|
||||||
|
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $service->approve($requested, $approver, [
|
||||||
|
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
'approval_reason' => 'Browser smoke approval.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('smokes remediated standard record pages with contextual navigation and one clear next step', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Spec192 Browser Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$onboardingTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Spec192 Browser Onboarding Tenant',
|
||||||
|
]);
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $onboardingTenant,
|
||||||
|
user: $user,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $onboardingTenant->workspace,
|
||||||
|
'tenant' => $onboardingTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $onboardingTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $onboardingTenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Spec192 Browser Baseline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineSnapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
|
||||||
|
|
||||||
|
\App\Models\BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||||
|
'summary' => ['finding_count' => 2],
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = spec192ApprovedFindingException($tenant, $user);
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
visit(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Review compare matrix')
|
||||||
|
->assertSee('Compare now');
|
||||||
|
|
||||||
|
visit(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Review pack')
|
||||||
|
->assertSee('Refresh evidence');
|
||||||
|
|
||||||
|
visit(FindingExceptionResource::getUrl('view', ['record' => $exception], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Open finding')
|
||||||
|
->assertSee('Renew exception');
|
||||||
|
|
||||||
|
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Artifact truth')
|
||||||
|
->assertSee('Evidence snapshot');
|
||||||
|
|
||||||
|
visit(TenantResource::getUrl('edit', ['record' => $onboardingTenant], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Resume onboarding')
|
||||||
|
->assertSee('Open tenant detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes the explicit workflow-heavy tenant detail exception without javascript errors', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Spec192 Workflow Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
visit(TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length === 0", true)
|
||||||
|
->assertSee('Edit tenant')
|
||||||
|
->assertSee('Open provider connections');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes the compliant reference baseline without header regressions or javascript errors', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create([
|
||||||
|
'name' => 'Spec192 Reference Tenant',
|
||||||
|
]);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$workspace = $tenant->workspace;
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'name' => 'Spec192 Reference Baseline',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineSnapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $baselineSnapshot->getKey()]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'display_name' => 'Spec192 Browser Policy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'policy_id' => (int) $policy->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'version_number' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => (int) $baselineSnapshot->getKey(),
|
||||||
|
'meta_jsonb' => [
|
||||||
|
'display_name' => 'Spec192 Browser Policy',
|
||||||
|
'version_reference' => [
|
||||||
|
'policy_version_id' => (int) $version->getKey(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'name' => 'Spec192 Browser Backup',
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->forTenant($tenant)->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'backup_set.add_policies',
|
||||||
|
'context' => [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reviewSnapshot = seedTenantReviewEvidence($tenant);
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user, $reviewSnapshot);
|
||||||
|
|
||||||
|
$pack = ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_review_id' => (int) $review->getKey(),
|
||||||
|
'evidence_snapshot_id' => (int) $reviewSnapshot->getKey(),
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$destination = AlertDestination::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'name' => 'Spec192 Browser Destination',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
visit(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant))
|
||||||
|
->waitForText('Download')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Regenerate');
|
||||||
|
|
||||||
|
visit(AlertDestinationResource::getUrl('view', ['record' => $destination], panel: 'admin'))
|
||||||
|
->waitForText('Send test message')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Details');
|
||||||
|
|
||||||
|
visit(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('View snapshot');
|
||||||
|
|
||||||
|
visit(WorkspaceResource::getUrl('view', ['record' => $workspace], panel: 'admin'))
|
||||||
|
->waitForText('Memberships')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertSee('Edit');
|
||||||
|
|
||||||
|
visit(BaselineSnapshotResource::getUrl('view', ['record' => $baselineSnapshot], panel: 'admin'))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
|
||||||
|
->assertSee('Spec192 Browser Policy');
|
||||||
|
|
||||||
|
visit(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||||
|
->waitForText('Related context')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->assertScript("document.querySelectorAll('[data-supporting-group-kind]').length > 0", true)
|
||||||
|
->assertSee('Operations');
|
||||||
|
});
|
||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
use App\Models\StoredReport;
|
use App\Models\StoredReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -20,6 +21,7 @@
|
|||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
@ -53,6 +55,17 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
OperationRun::factory()->forTenant($tenant)->create();
|
OperationRun::factory()->forTenant($tenant)->create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function evidenceSnapshotHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
it('renders the evidence list page for an authorized user', function (): void {
|
it('renders the evidence list page for an authorized user', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
@ -125,27 +138,47 @@ function seedEvidenceDomain(Tenant $tenant): void
|
|||||||
it('renders the view page for an active snapshot', function (): void {
|
it('renders the view page for an active snapshot', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
$run = OperationRun::factory()->forTenant($tenant)->create();
|
||||||
|
|
||||||
$snapshot = EvidenceSnapshot::query()->create([
|
$snapshot = EvidenceSnapshot::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
'status' => EvidenceSnapshotStatus::Active->value,
|
||||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||||
'summary' => ['finding_count' => 2],
|
'summary' => ['finding_count' => 2],
|
||||||
'generated_at' => now(),
|
'generated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
ReviewPack::factory()->ready()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'initiated_by_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
|
||||||
->assertOk();
|
->assertOk()
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Review pack');
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
||||||
->assertActionVisible('refresh_snapshot')
|
->assertActionVisible('refresh_snapshot')
|
||||||
->assertActionVisible('expire_snapshot');
|
->assertActionVisible('expire_snapshot');
|
||||||
|
|
||||||
|
expect(collect(evidenceSnapshotHeaderActions($component))
|
||||||
|
->map(static fn ($action): ?string => method_exists($action, 'getName') ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all())
|
||||||
|
->toEqualCanonicalizing(['refresh_snapshot', 'expire_snapshot'])
|
||||||
|
->and(collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))->pluck('key')->all())
|
||||||
|
->toContain('operation_run', 'review_pack');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
||||||
|
|||||||
@ -9,12 +9,26 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function baselineProfileCaptureHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
|
it('redirects unauthenticated users (302) when accessing the capture start surface', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
@ -78,7 +92,7 @@
|
|||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
->assertActionVisible('capture')
|
->assertActionVisible('capture')
|
||||||
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
->assertActionHasLabel('capture', 'Capture baseline (full content)')
|
||||||
@ -86,6 +100,16 @@
|
|||||||
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
->callAction('capture', data: ['source_tenant_id' => (int) $tenant->getKey()])
|
||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
|
|
||||||
|
$topLevelActionNames = collect(baselineProfileCaptureHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($topLevelActionNames)->toBe(['capture']);
|
||||||
|
|
||||||
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
Queue::assertPushed(CaptureBaselineSnapshotJob::class);
|
||||||
|
|
||||||
$run = OperationRun::query()
|
$run = OperationRun::query()
|
||||||
|
|||||||
@ -10,9 +10,22 @@
|
|||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
function baselineProfileHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
it('does not start baseline compare for workspace members missing tenant.sync', function (): void {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
config()->set('tenantpilot.baselines.full_content_capture.enabled', true);
|
||||||
@ -135,7 +148,7 @@
|
|||||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows open-compare-matrix and compare-assigned-tenants header actions with simulation-only copy', function (): void {
|
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
@ -158,16 +171,35 @@
|
|||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
->assertActionExists('openCompareMatrix', fn (Action $action): bool => $action->getLabel() === 'Open compare matrix'
|
|
||||||
&& $action->getUrl() === BaselineProfileResource::compareMatrixUrl($profile))
|
|
||||||
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
|
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getLabel() === 'Compare assigned tenants'
|
||||||
&& $action->isConfirmationRequired()
|
&& $action->isConfirmationRequired()
|
||||||
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
|
&& str_contains((string) $action->getModalDescription(), 'Simulation only.'));
|
||||||
|
|
||||||
|
$topLevelActionNames = collect(baselineProfileHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$moreGroup = collect(baselineProfileHeaderActions($component))
|
||||||
|
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible());
|
||||||
|
$moreActionNames = collect($moreGroup?->getActions() ?? [])
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($topLevelActionNames)->toBe(['compareNow'])
|
||||||
|
->and($moreGroup)->toBeInstanceOf(ActionGroup::class)
|
||||||
|
->and($moreActionNames)->toEqualCanonicalizing(['compareAssignedTenants', 'edit'])
|
||||||
|
->and(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||||
|
->toContain('compare_matrix', 'baseline_snapshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members', function (): void {
|
it('keeps compare-assigned-tenants visible but disabled for readonly workspace members after the navigation move', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$profile = BaselineProfile::factory()->active()->create([
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
@ -191,7 +223,9 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
->assertActionVisible('openCompareMatrix')
|
|
||||||
->assertActionVisible('compareAssignedTenants')
|
->assertActionVisible('compareAssignedTenants')
|
||||||
->assertActionDisabled('compareAssignedTenants');
|
->assertActionDisabled('compareAssignedTenants');
|
||||||
|
|
||||||
|
expect(collect(BaselineProfileResource::detailRelatedContextEntries($profile))->pluck('key')->all())
|
||||||
|
->toContain('compare_matrix');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
|
||||||
|
it('keeps database notifications enabled without background polling on every panel', function (): void {
|
||||||
|
foreach (['admin', 'tenant', 'system'] as $panelId) {
|
||||||
|
$panel = Filament::getPanel($panelId);
|
||||||
|
|
||||||
|
expect($panel->hasDatabaseNotifications())->toBeTrue();
|
||||||
|
expect($panel->getDatabaseNotificationsPollingInterval())->toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the admin notifications modal without a polling attribute', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get('/admin');
|
||||||
|
|
||||||
|
$response->assertSuccessful();
|
||||||
|
|
||||||
|
$html = $response->getContent();
|
||||||
|
|
||||||
|
expect($html)->toContain('wire:name="Filament\\Livewire\\DatabaseNotifications"');
|
||||||
|
|
||||||
|
preg_match('/<[^>]+wire:name="Filament\\\\Livewire\\\\DatabaseNotifications"[^>]*>/', $html, $matches);
|
||||||
|
|
||||||
|
expect($matches)->not->toBeEmpty('Expected the admin page to render the database notifications Livewire root element.');
|
||||||
|
expect($matches[0])->not->toContain('wire:poll');
|
||||||
|
expect($matches[0])->not->toContain('wire:poll.30s');
|
||||||
|
});
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function editTenantHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTenantHeaderGroupLabels(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(editTenantHeaderActions($component))
|
||||||
|
->filter(static fn ($action): bool => $action instanceof ActionGroup && $action->isVisible())
|
||||||
|
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editTenantHeaderPrimaryNames(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(editTenantHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->filter(static fn ($action): bool => ! method_exists($action, 'isVisible') || $action->isVisible())
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps related links in contextual placement and reserves the header for lifecycle actions', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Open tenant detail')
|
||||||
|
->assertSee('Resume onboarding');
|
||||||
|
|
||||||
|
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
||||||
|
->and(editTenantHeaderGroupLabels($component))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenant lifecycle mutations available under the lifecycle header group with confirmation intact', function (): void {
|
||||||
|
$tenant = Tenant::factory()->active()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->assertActionVisible('archive')
|
||||||
|
->assertActionEnabled('archive')
|
||||||
|
->assertActionExists('archive', fn (Action $action): bool => $action->isConfirmationRequired());
|
||||||
|
|
||||||
|
expect(editTenantHeaderPrimaryNames($component))->toBe([])
|
||||||
|
->and(editTenantHeaderGroupLabels($component))->toBe(['Lifecycle']);
|
||||||
|
});
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Findings\FindingExceptionService;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function findingExceptionHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findingExceptionHeaderNames(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(findingExceptionHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps finding navigation out of the header while preserving renewal and revocation actions', function (): void {
|
||||||
|
[$requester, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$approver = User::factory()->create();
|
||||||
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner', workspaceRole: 'manager');
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** @var FindingExceptionService $service */
|
||||||
|
$service = app(FindingExceptionService::class);
|
||||||
|
|
||||||
|
$requested = $service->request($finding, $tenant, $requester, [
|
||||||
|
'owner_user_id' => (int) $requester->getKey(),
|
||||||
|
'request_reason' => 'Existing compensating controls remain in place.',
|
||||||
|
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exception = $service->approve($requested, $approver, [
|
||||||
|
'effective_from' => now()->subDay()->toDateTimeString(),
|
||||||
|
'expires_at' => now()->addDays(14)->toDateTimeString(),
|
||||||
|
'approval_reason' => 'Accepted while remediation is scheduled.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($requester);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$component = Livewire::test(ViewFindingException::class, ['record' => $exception->getKey()])
|
||||||
|
->assertActionVisible('renew_exception')
|
||||||
|
->assertActionVisible('revoke_exception')
|
||||||
|
->assertActionExists('revoke_exception', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Approval queue')
|
||||||
|
->assertSee('Open finding');
|
||||||
|
|
||||||
|
expect(findingExceptionHeaderNames($component))
|
||||||
|
->toEqualCanonicalizing(['renew_exception', 'revoke_exception'])
|
||||||
|
->not->toContain('open_finding', 'open_approval_queue');
|
||||||
|
});
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
||||||
|
use App\Models\TenantReview;
|
||||||
|
use App\Support\TenantReviewStatus;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function tenantReviewHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantReviewHeaderPrimaryNames(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(tenantReviewHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantReviewHeaderGroupLabels(Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(tenantReviewHeaderActions($component))
|
||||||
|
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps ready reviews to one primary action and renders related navigation in the summary context', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user);
|
||||||
|
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Evidence snapshot');
|
||||||
|
|
||||||
|
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['publish_review'])
|
||||||
|
->and(tenantReviewHeaderGroupLabels($component))->toBe(['More', 'Danger']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('promotes executive-pack export as the only visible primary action after publication', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$review = composeTenantReviewForTest($tenant, $user);
|
||||||
|
|
||||||
|
$review->forceFill([
|
||||||
|
'status' => TenantReviewStatus::Published->value,
|
||||||
|
'published_at' => now(),
|
||||||
|
'published_by_user_id' => (int) $user->getKey(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)
|
||||||
|
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
||||||
|
->assertActionVisible('export_executive_pack')
|
||||||
|
->assertActionEnabled('export_executive_pack');
|
||||||
|
|
||||||
|
expect(tenantReviewHeaderPrimaryNames($component))->toBe(['export_executive_pack'])
|
||||||
|
->and(tenantReviewHeaderGroupLabels($component))->toContain('More')
|
||||||
|
->and(tenantReviewHeaderPrimaryNames($component))->not->toContain('refresh_review', 'publish_review');
|
||||||
|
});
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -14,7 +15,7 @@
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
describe('Tenant View header action UI enforcement', function () {
|
describe('Tenant View header action UI enforcement', function () {
|
||||||
it('shows edit and archive actions as visible but disabled for readonly members', function () {
|
it('keeps archive visible in the workflow header and moves edit/provider navigation into contextual unavailable entries for readonly members', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -23,19 +24,19 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('edit')
|
|
||||||
->assertActionDisabled('edit')
|
|
||||||
->assertActionExists('edit', function (Action $action): bool {
|
|
||||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
|
||||||
})
|
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionDisabled('archive')
|
->assertActionDisabled('archive')
|
||||||
->assertActionExists('archive', function (Action $action): bool {
|
->assertActionExists('archive', function (Action $action): bool {
|
||||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
|
||||||
|
|
||||||
|
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('authorization_denied')
|
||||||
|
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows edit and archive actions as enabled for owner members', function () {
|
it('keeps archive enabled for owner members and exposes edit/provider navigation in contextual related content', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
@ -44,10 +45,13 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('edit')
|
|
||||||
->assertActionEnabled('edit')
|
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionEnabled('archive');
|
->assertActionEnabled('archive');
|
||||||
|
|
||||||
|
$contextEntries = collect(TenantResource::tenantViewContextEntries($tenant))->keyBy('key');
|
||||||
|
|
||||||
|
expect($contextEntries->get('tenant_edit')['availability'] ?? null)->toBe('available')
|
||||||
|
->and($contextEntries->get('provider_connections')['availability'] ?? null)->toBe('available');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not execute the archive action for readonly members (silently blocked by Filament)', function () {
|
it('does not execute the archive action for readonly members (silently blocked by Filament)', function () {
|
||||||
@ -85,11 +89,9 @@
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
Filament::setTenant(null, true);
|
Filament::setTenant(null, true);
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
||||||
->assertActionVisible('related_onboarding')
|
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
||||||
->assertActionExists('related_onboarding', function (Action $action): bool {
|
->toBe('Resume onboarding');
|
||||||
return $action->getLabel() === 'Resume onboarding';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a cancelled-onboarding label and repairs stale onboarding tenant status when the linked draft was cancelled', function () {
|
it('shows a cancelled-onboarding label and repairs stale onboarding tenant status when the linked draft was cancelled', function () {
|
||||||
@ -126,10 +128,8 @@
|
|||||||
->where('action', \App\Support\Audit\AuditActionId::TenantReturnedToDraft->value)
|
->where('action', \App\Support\Audit\AuditActionId::TenantReturnedToDraft->value)
|
||||||
->exists())->toBeTrue();
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
||||||
->assertActionVisible('related_onboarding')
|
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
||||||
->assertActionExists('related_onboarding', function (Action $action): bool {
|
->toBe('View cancelled onboarding draft');
|
||||||
return $action->getLabel() === 'View cancelled onboarding draft';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,6 +15,13 @@
|
|||||||
expect($js)
|
expect($js)
|
||||||
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
|
->toContain('__tenantpilotUnhandledRejectionLoggerApplied')
|
||||||
->toContain("window.addEventListener('unhandledrejection'")
|
->toContain("window.addEventListener('unhandledrejection'")
|
||||||
|
->toContain('window.fetch = async (...args) =>')
|
||||||
|
->toContain('XMLHttpRequest.prototype.open = function (method, url, ...rest)')
|
||||||
|
->toContain('const transport = resolveTransportMetadata(normalizedReason)')
|
||||||
|
->toContain('requestUrl: transport?.requestUrl ?? null')
|
||||||
|
->toContain('requestMethod: transport?.method ?? null')
|
||||||
|
->toContain('transportType: transport?.transportType ?? null')
|
||||||
|
->toContain('requestUrl: payload.requestUrl')
|
||||||
->toContain('isExpectedBackgroundTransportFailure')
|
->toContain('isExpectedBackgroundTransportFailure')
|
||||||
->toContain("document.visibilityState !== 'visible'")
|
->toContain("document.visibilityState !== 'visible'")
|
||||||
->toContain('document.hasFocus')
|
->toContain('document.hasFocus')
|
||||||
|
|||||||
@ -1996,3 +1996,45 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
|
||||||
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
expect((string) $run?->initiator_name)->toBe((string) $user->name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('documents the spec 192 workflow-heavy exception and reference inventory', function (): void {
|
||||||
|
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
|
||||||
|
$workflowHeavy = collect($inventory)
|
||||||
|
->filter(fn (array $surface): bool => $surface['classification'] === 'workflow_heavy_special_type')
|
||||||
|
->keys()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$referencePages = collect($inventory)
|
||||||
|
->filter(fn (array $surface): bool => $surface['classification'] === 'compliant_reference')
|
||||||
|
->keys()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($workflowHeavy)->toBe([\App\Filament\Resources\TenantResource\Pages\ViewTenant::class])
|
||||||
|
->and($referencePages)->toEqualCanonicalizing([
|
||||||
|
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
|
||||||
|
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
|
||||||
|
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
|
||||||
|
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
|
||||||
|
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
|
||||||
|
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps spec 192 remediated pages out of the enterprise-detail layout rollout', function (): void {
|
||||||
|
foreach ([
|
||||||
|
\App\Filament\Resources\BaselineProfileResource::class,
|
||||||
|
\App\Filament\Resources\EvidenceSnapshotResource::class,
|
||||||
|
\App\Filament\Resources\FindingExceptionResource::class,
|
||||||
|
\App\Filament\Resources\TenantReviewResource::class,
|
||||||
|
\App\Filament\Resources\TenantResource::class,
|
||||||
|
\App\Filament\Resources\TenantResource\Pages\EditTenant::class,
|
||||||
|
\App\Filament\Resources\TenantResource\Pages\ViewTenant::class,
|
||||||
|
] as $className) {
|
||||||
|
$source = file_get_contents((string) (new ReflectionClass($className))->getFileName()) ?: '';
|
||||||
|
|
||||||
|
expect($source)
|
||||||
|
->not->toContain('EnterpriseDetail')
|
||||||
|
->not->toContain('enterprise-detail/header');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -229,3 +229,11 @@ className: $className,
|
|||||||
expect($result->hasIssues())->toBeTrue();
|
expect($result->hasIssues())->toBeTrue();
|
||||||
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
|
expect($result->formatForAssertion())->toContain('Slot is marked exempt but exemption reason is missing or empty');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts the repository spec 192 inventory even when only inventory validation runs', function (): void {
|
||||||
|
$validator = ActionSurfaceValidator::withBaselineExemptions();
|
||||||
|
|
||||||
|
$result = $validator->validateComponents([]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
||||||
|
use App\Filament\Resources\FindingExceptionResource;
|
||||||
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
|
||||||
|
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
|
||||||
|
|
||||||
|
function spec192RecordPageSource(string $className): string
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass($className);
|
||||||
|
$path = $reflection->getFileName();
|
||||||
|
|
||||||
|
expect($path)->toBeString();
|
||||||
|
|
||||||
|
return file_get_contents((string) $path) ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps the spec 192 record-page inventory complete and explicitly classified', function (): void {
|
||||||
|
$inventory = ActionSurfaceExemptions::spec192RecordPageInventory();
|
||||||
|
|
||||||
|
expect(array_keys($inventory))->toEqualCanonicalizing([
|
||||||
|
\App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile::class,
|
||||||
|
\App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot::class,
|
||||||
|
\App\Filament\Resources\FindingExceptionResource\Pages\ViewFindingException::class,
|
||||||
|
\App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview::class,
|
||||||
|
EditTenant::class,
|
||||||
|
ViewTenant::class,
|
||||||
|
\App\Filament\Resources\ProviderConnectionResource\Pages\ViewProviderConnection::class,
|
||||||
|
\App\Filament\Resources\FindingResource\Pages\ViewFinding::class,
|
||||||
|
\App\Filament\Resources\ReviewPackResource\Pages\ViewReviewPack::class,
|
||||||
|
\App\Filament\Resources\AlertDestinationResource\Pages\ViewAlertDestination::class,
|
||||||
|
\App\Filament\Resources\PolicyVersionResource\Pages\ViewPolicyVersion::class,
|
||||||
|
\App\Filament\Resources\Workspaces\Pages\ViewWorkspace::class,
|
||||||
|
\App\Filament\Resources\BaselineSnapshotResource\Pages\ViewBaselineSnapshot::class,
|
||||||
|
\App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class))
|
||||||
|
->not->toBeNull()
|
||||||
|
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['classification'] ?? null)->toBe('workflow_heavy_special_type')
|
||||||
|
->and(ActionSurfaceExemptions::spec192RecordPageSurface(ViewTenant::class)['exceptionReason'] ?? null)->toContain('workflow-heavy hub')
|
||||||
|
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['classification'] ?? null)->toBe('remediation_required')
|
||||||
|
->and(ActionSurfaceExemptions::spec192RecordPageSurface(EditTenant::class)['allowsNoPrimaryAction'] ?? null)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the spec 192 inventory valid inside the action-surface validator', function (): void {
|
||||||
|
$result = ActionSurfaceValidator::withBaselineExemptions()->validateComponents([]);
|
||||||
|
|
||||||
|
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps remediated spec 192 pages off the enterprise-detail body-layout builder', function (): void {
|
||||||
|
foreach ([
|
||||||
|
BaselineProfileResource::class,
|
||||||
|
EvidenceSnapshotResource::class,
|
||||||
|
FindingExceptionResource::class,
|
||||||
|
TenantReviewResource::class,
|
||||||
|
TenantResource::class,
|
||||||
|
EditTenant::class,
|
||||||
|
ViewTenant::class,
|
||||||
|
] as $className) {
|
||||||
|
expect(spec192RecordPageSource($className))
|
||||||
|
->not->toContain('EnterpriseDetail')
|
||||||
|
->not->toContain('enterprise-detail/header');
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -5,9 +5,22 @@
|
|||||||
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
use App\Filament\Resources\TenantResource\Pages\EditTenant;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
function editTenantUiHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
describe('Edit tenant archive action UI enforcement', function () {
|
describe('Edit tenant archive action UI enforcement', function () {
|
||||||
it('shows archive action as visible but disabled for manager members', function () {
|
it('shows archive action as visible but disabled for manager members', function () {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
@ -18,7 +31,7 @@
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionDisabled('archive')
|
->assertActionDisabled('archive')
|
||||||
->assertActionExists('archive', function (Action $action): bool {
|
->assertActionExists('archive', function (Action $action): bool {
|
||||||
@ -30,6 +43,12 @@
|
|||||||
->callMountedAction()
|
->callMountedAction()
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(collect(editTenantUiHeaderActions($component))
|
||||||
|
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||||
|
->values()
|
||||||
|
->all())->toBe(['Lifecycle']);
|
||||||
|
|
||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
expect($tenant->trashed())->toBeFalse();
|
expect($tenant->trashed())->toBeFalse();
|
||||||
});
|
});
|
||||||
@ -43,7 +62,7 @@
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionEnabled('archive')
|
->assertActionEnabled('archive')
|
||||||
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
|
->assertActionExists('archive', fn (Action $action): bool => $action->getLabel() === 'Archive' && $action->isConfirmationRequired())
|
||||||
@ -51,6 +70,12 @@
|
|||||||
->callMountedAction()
|
->callMountedAction()
|
||||||
->assertHasNoActionErrors();
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
expect(collect(editTenantUiHeaderActions($component))
|
||||||
|
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||||
|
->values()
|
||||||
|
->all())->toBe(['Lifecycle']);
|
||||||
|
|
||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
expect($tenant->trashed())->toBeTrue();
|
expect($tenant->trashed())->toBeTrue();
|
||||||
});
|
});
|
||||||
@ -75,10 +100,16 @@
|
|||||||
|
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
$component = Livewire::test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('restore')
|
->assertActionVisible('restore')
|
||||||
->assertActionEnabled('restore')
|
->assertActionEnabled('restore')
|
||||||
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired())
|
->assertActionExists('restore', fn (Action $action): bool => $action->getLabel() === 'Restore' && $action->isConfirmationRequired())
|
||||||
->assertActionHidden('archive');
|
->assertActionHidden('archive');
|
||||||
|
|
||||||
|
expect(collect(editTenantUiHeaderActions($component))
|
||||||
|
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||||
|
->values()
|
||||||
|
->all())->toBe(['Lifecycle']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -48,15 +48,19 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('related_onboarding')
|
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||||
|
->toContain('related_onboarding');
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('related_onboarding')
|
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
||||||
|
->toContain('related_onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps active lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
it('keeps active lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
@ -76,14 +80,18 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionHidden('restore')
|
->assertActionHidden('restore');
|
||||||
->assertActionHidden('related_onboarding');
|
|
||||||
|
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||||
|
->not->toContain('related_onboarding');
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('archive')
|
->assertActionVisible('archive')
|
||||||
->assertActionHidden('restore')
|
->assertActionHidden('restore');
|
||||||
->assertActionHidden('related_onboarding');
|
|
||||||
|
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
||||||
|
->not->toContain('related_onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps draft lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
it('keeps draft lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
@ -113,15 +121,19 @@ function tenantActionSurfaceSearchTitles($results): array
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('related_onboarding')
|
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||||
|
->toContain('related_onboarding');
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(EditTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('related_onboarding')
|
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
expect(collect(TenantResource::tenantEditContextEntries($tenant))->pluck('key')->all())
|
||||||
|
->toContain('related_onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps archived lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
it('keeps archived lifecycle actions consistent across list, view, and edit surfaces', function (): void {
|
||||||
|
|||||||
@ -45,10 +45,12 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('related_onboarding')
|
|
||||||
->assertActionExists('related_onboarding', fn (Action $action): bool => $action->getLabel() === 'Resume onboarding')
|
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionHidden('restore');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
expect(collect(TenantResource::tenantViewContextEntries($tenant))
|
||||||
|
->firstWhere('key', 'related_onboarding')['value'] ?? null)
|
||||||
|
->toBe('Resume onboarding');
|
||||||
})->with([
|
})->with([
|
||||||
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
'draft' => [fn (): Tenant => Tenant::factory()->draft()->create()],
|
||||||
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
'onboarding' => [fn (): Tenant => Tenant::factory()->onboarding()->create()],
|
||||||
@ -91,8 +93,10 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
->assertActionVisible('restore')
|
->assertActionVisible('restore')
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive');
|
||||||
->assertActionHidden('related_onboarding');
|
|
||||||
|
expect(collect(TenantResource::tenantViewContextEntries($tenant))->pluck('key')->all())
|
||||||
|
->not->toContain('related_onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows verification only for active tenants on administrative list and detail surfaces', function (
|
it('shows verification only for active tenants on administrative list and detail surfaces', function (
|
||||||
@ -190,7 +194,10 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $onboardingTenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $onboardingTenant->getRouteKey()])
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive')
|
||||||
->assertActionVisible('related_onboarding');
|
->assertActionHidden('restore');
|
||||||
|
|
||||||
|
expect(collect(TenantResource::tenantViewContextEntries($onboardingTenant))->pluck('key')->all())
|
||||||
|
->toContain('related_onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
it('returns 404 on tenant detail routes for non-members regardless of lifecycle state', function (\Closure $tenantFactory): void {
|
||||||
@ -228,8 +235,10 @@
|
|||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewTenant::class, ['record' => $archivedTenant->getRouteKey()])
|
->test(ViewTenant::class, ['record' => $archivedTenant->getRouteKey()])
|
||||||
->assertActionVisible('restore')
|
->assertActionVisible('restore')
|
||||||
->assertActionHidden('archive')
|
->assertActionHidden('archive');
|
||||||
->assertActionHidden('related_onboarding');
|
|
||||||
|
expect(collect(TenantResource::tenantViewContextEntries($archivedTenant))->pluck('key')->all())
|
||||||
|
->not->toContain('related_onboarding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses lifecycle-invalid archive and restore mutations without changing tenant state', function (): void {
|
it('refuses lifecycle-invalid archive and restore mutations without changing tenant state', function (): void {
|
||||||
|
|||||||
@ -11,8 +11,22 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
function tenantReviewContractHeaderActions(Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
it('disables tenant-review global search while keeping the view page available for resource inspection', function (): void {
|
it('disables tenant-review global search while keeping the view page available for resource inspection', function (): void {
|
||||||
$reflection = new ReflectionClass(TenantReviewResource::class);
|
$reflection = new ReflectionClass(TenantReviewResource::class);
|
||||||
|
|
||||||
@ -103,6 +117,37 @@
|
|||||||
->assertActionMounted('archive_review');
|
->assertActionMounted('archive_review');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps tenant review header hierarchy to one primary action and moves related links into summary context', function (): void {
|
||||||
|
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$review = composeTenantReviewForTest($tenant, $owner);
|
||||||
|
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
$this->actingAs($owner)
|
||||||
|
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Related context')
|
||||||
|
->assertSee('Evidence snapshot');
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($owner)
|
||||||
|
->test(ViewTenantReview::class, ['record' => $review->getKey()]);
|
||||||
|
|
||||||
|
$topLevelActionNames = collect(tenantReviewContractHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
$groupLabels = collect(tenantReviewContractHeaderActions($component))
|
||||||
|
->filter(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn (ActionGroup $action): string => (string) $action->getLabel())
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($topLevelActionNames)->toBe(['publish_review'])
|
||||||
|
->and($groupLabels)->toBe(['More', 'Danger']);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
|
it('shows publication truth and next-step guidance when a review is not yet publishable', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Record Page Header Discipline & Contextual Navigation
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-11
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validated on first pass against the completed spec.
|
||||||
|
- No clarification markers remain.
|
||||||
|
- The spec keeps the cleanup bounded to classic record/detail/edit surfaces and documents the tenant admin resource view as the only explicit special-type exception.
|
||||||
@ -0,0 +1,252 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Record Page Header Discipline Internal Surface Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Internal logical contract for Spec 192 record-page header discipline
|
||||||
|
description: |
|
||||||
|
This contract is an internal planning artifact for Spec 192. The affected
|
||||||
|
surfaces continue to render HTML through Filament and Livewire. The schemas
|
||||||
|
below define the bounded render contract and regression expectations for
|
||||||
|
standard record/detail/edit headers, grouped secondary actions, contextual
|
||||||
|
navigation outside the header, and the explicit workflow-heavy exception.
|
||||||
|
servers:
|
||||||
|
- url: /internal
|
||||||
|
x-record-header-discipline-consumers:
|
||||||
|
- surface: standard-record-pages
|
||||||
|
sourceFiles:
|
||||||
|
- apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php
|
||||||
|
- apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php
|
||||||
|
- apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php
|
||||||
|
- apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php
|
||||||
|
- apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php
|
||||||
|
mustRender:
|
||||||
|
- at_most_one_primary_header_action
|
||||||
|
- grouped_secondary_actions
|
||||||
|
- contextual_navigation_or_related_context_outside_header
|
||||||
|
- separated_danger_actions_when_present
|
||||||
|
mustNotRender:
|
||||||
|
- flat_navigation_mutation_strip
|
||||||
|
- multiple_competing_primary_actions
|
||||||
|
- empty_action_group_placeholders
|
||||||
|
- surface: workflow-heavy-special-type
|
||||||
|
sourceFiles:
|
||||||
|
- apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php
|
||||||
|
mustRender:
|
||||||
|
- explicit_exception_reason
|
||||||
|
- grouped_and_ordered_actions
|
||||||
|
- optional_single_primary_only_when_dominant
|
||||||
|
mustNotRender:
|
||||||
|
- silent_exception
|
||||||
|
- flat_multi_button_primary_strip
|
||||||
|
- surface: regression-guards
|
||||||
|
sourceFiles:
|
||||||
|
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php
|
||||||
|
- apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php
|
||||||
|
- apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
- apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||||
|
paths:
|
||||||
|
/internal/action-surfaces/record-pages/{surface}:
|
||||||
|
get:
|
||||||
|
summary: Return the logical header-discipline contract for an in-scope record page
|
||||||
|
operationId: getRecordPageHeaderDisciplineContract
|
||||||
|
parameters:
|
||||||
|
- name: surface
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SurfaceKey'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Logical render contract and regression expectations for the requested surface
|
||||||
|
content:
|
||||||
|
application/vnd.tenantpilot.record-header-discipline+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RecordHeaderSurfaceContract'
|
||||||
|
'404':
|
||||||
|
description: Requested surface is not in the Spec 192 inventory
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
SurfaceKey:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- baseline_profile_view
|
||||||
|
- evidence_snapshot_view
|
||||||
|
- finding_exception_view
|
||||||
|
- tenant_review_view
|
||||||
|
- tenant_edit
|
||||||
|
- tenant_view
|
||||||
|
- provider_connection_view
|
||||||
|
- finding_view
|
||||||
|
- review_pack_view
|
||||||
|
- alert_destination_view
|
||||||
|
- policy_version_view
|
||||||
|
- workspace_view
|
||||||
|
- baseline_snapshot_view
|
||||||
|
- backup_set_view
|
||||||
|
SurfaceClassification:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- remediation_required
|
||||||
|
- minor_alignment_only
|
||||||
|
- compliant_reference
|
||||||
|
- workflow_heavy_special_type
|
||||||
|
ActionKind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- navigation
|
||||||
|
- mutation
|
||||||
|
- external_link
|
||||||
|
- lifecycle
|
||||||
|
- danger
|
||||||
|
Placement:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- primary_visible
|
||||||
|
- secondary_grouped
|
||||||
|
- contextual
|
||||||
|
- danger_grouped
|
||||||
|
OperationScope:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- TenantPilot only
|
||||||
|
- Microsoft tenant
|
||||||
|
- simulation only
|
||||||
|
- read-only
|
||||||
|
HeaderActionDescriptor:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- actionKey
|
||||||
|
- label
|
||||||
|
- actionKind
|
||||||
|
- placement
|
||||||
|
- requiresConfirmation
|
||||||
|
- usesUiEnforcement
|
||||||
|
- operationScope
|
||||||
|
properties:
|
||||||
|
actionKey:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
actionKind:
|
||||||
|
$ref: '#/components/schemas/ActionKind'
|
||||||
|
placement:
|
||||||
|
$ref: '#/components/schemas/Placement'
|
||||||
|
requiresConfirmation:
|
||||||
|
type: boolean
|
||||||
|
usesUiEnforcement:
|
||||||
|
type: boolean
|
||||||
|
capabilityKey:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
writesAuditLog:
|
||||||
|
type: boolean
|
||||||
|
operationScope:
|
||||||
|
$ref: '#/components/schemas/OperationScope'
|
||||||
|
ContextualNavigationEntry:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- sourceSection
|
||||||
|
- isAvailable
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
targetUrl:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
sourceSection:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- summary
|
||||||
|
- related_context
|
||||||
|
- field_context
|
||||||
|
- status_context
|
||||||
|
isAvailable:
|
||||||
|
type: boolean
|
||||||
|
SecondaryActionGroup:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- orderedBuckets
|
||||||
|
- actions
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
orderedBuckets:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
actions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/HeaderActionDescriptor'
|
||||||
|
HeaderRegressionExpectation:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- maxVisiblePrimaryActions
|
||||||
|
- requiresGroupedSecondaryActions
|
||||||
|
- allowsPrimaryNavigation
|
||||||
|
- requiresExplicitExceptionReason
|
||||||
|
properties:
|
||||||
|
maxVisiblePrimaryActions:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
requiresGroupedSecondaryActions:
|
||||||
|
type: boolean
|
||||||
|
allowsPrimaryNavigation:
|
||||||
|
type: boolean
|
||||||
|
requiresDangerSeparation:
|
||||||
|
type: boolean
|
||||||
|
requiresExplicitExceptionReason:
|
||||||
|
type: boolean
|
||||||
|
browserSmokeRequired:
|
||||||
|
type: boolean
|
||||||
|
RecordHeaderSurfaceContract:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- surfaceKey
|
||||||
|
- classification
|
||||||
|
- canonicalNoun
|
||||||
|
- primaryQuestion
|
||||||
|
- actions
|
||||||
|
- contextualNavigation
|
||||||
|
- regressionExpectation
|
||||||
|
properties:
|
||||||
|
surfaceKey:
|
||||||
|
$ref: '#/components/schemas/SurfaceKey'
|
||||||
|
classification:
|
||||||
|
$ref: '#/components/schemas/SurfaceClassification'
|
||||||
|
canonicalNoun:
|
||||||
|
type: string
|
||||||
|
primaryQuestion:
|
||||||
|
type: string
|
||||||
|
primaryAction:
|
||||||
|
anyOf:
|
||||||
|
- $ref: '#/components/schemas/HeaderActionDescriptor'
|
||||||
|
- type: 'null'
|
||||||
|
secondaryActionGroup:
|
||||||
|
anyOf:
|
||||||
|
- $ref: '#/components/schemas/SecondaryActionGroup'
|
||||||
|
- type: 'null'
|
||||||
|
dangerActions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/HeaderActionDescriptor'
|
||||||
|
contextualNavigation:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ContextualNavigationEntry'
|
||||||
|
explicitExceptionReason:
|
||||||
|
type:
|
||||||
|
- string
|
||||||
|
- 'null'
|
||||||
|
regressionExpectation:
|
||||||
|
$ref: '#/components/schemas/HeaderRegressionExpectation'
|
||||||
155
specs/192-record-header-discipline/data-model.md
Normal file
155
specs/192-record-header-discipline/data-model.md
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
# Data Model: Record Page Header Discipline & Contextual Navigation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature introduces no new persisted entity, table, enum, or long-lived artifact. It reuses existing Filament pages, existing action definitions, and existing authorization helpers, while adding a derived planning model for how record-page headers are classified, rendered, and regression-tested.
|
||||||
|
|
||||||
|
## Existing Source Truths Reused Without Change
|
||||||
|
|
||||||
|
The following truths remain authoritative and are not redefined by this feature:
|
||||||
|
|
||||||
|
- existing resource and page routes
|
||||||
|
- existing model ownership and scope semantics
|
||||||
|
- existing capability checks and `UiEnforcement` behavior
|
||||||
|
- existing confirmation, audit, and `OperationRun` behavior for underlying actions
|
||||||
|
- existing related-navigation truth from `RelatedNavigationResolver` and related helper methods
|
||||||
|
|
||||||
|
This feature changes action hierarchy and placement only.
|
||||||
|
|
||||||
|
## New Derived Planning Models
|
||||||
|
|
||||||
|
### HeaderSurfaceInventoryEntry
|
||||||
|
|
||||||
|
**Type**: spec and guard inventory entry
|
||||||
|
**Source**: explicit Spec 192 classification matrix + action-surface regression guard
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `surfaceKey` | string | Stable identifier such as `baseline_profile_view` or `tenant_edit` |
|
||||||
|
| `pageClass` | string | Concrete Filament page class under review |
|
||||||
|
| `panelScope` | string | `admin`, `tenant`, or explicit special context |
|
||||||
|
| `ownerScope` | string | `workspace-owned` or `tenant-owned` |
|
||||||
|
| `classification` | string | `remediation_required`, `minor_alignment_only`, `compliant_reference`, or `workflow_heavy_special_type` |
|
||||||
|
| `canonicalNoun` | string | Stable operator-facing object noun |
|
||||||
|
| `routeKind` | string | `view` or `edit` |
|
||||||
|
| `requiresHeaderRemediation` | boolean | Whether the page must change under Spec 192 |
|
||||||
|
| `exceptionReason` | string or null | Required when classification is special-type |
|
||||||
|
|
||||||
|
### RecordHeaderLayoutState
|
||||||
|
|
||||||
|
**Type**: derived page render contract
|
||||||
|
**Source**: existing page action methods + page state + explicit Spec 192 rules
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `surfaceKey` | string | Links the render state back to the inventory entry |
|
||||||
|
| `classification` | string | Same classification used by the inventory |
|
||||||
|
| `primaryActionKey` | string or null | The one visible primary action, if any |
|
||||||
|
| `primaryActionLabel` | string or null | Operator-facing label for the visible primary action |
|
||||||
|
| `primaryActionReason` | string | Why this action is primary for the current page state |
|
||||||
|
| `secondaryGroupLabel` | string or null | Usually `More`, `Actions`, or equivalent grouped-secondary label |
|
||||||
|
| `hasContextualNavigation` | boolean | Whether pure navigation moved to contextual placement outside the header |
|
||||||
|
| `hasSeparatedDangerActions` | boolean | Whether dangerous actions are structurally separated |
|
||||||
|
| `allowsNoPrimaryAction` | boolean | True for compliant reference pages or special types without one dominant next step |
|
||||||
|
|
||||||
|
### HeaderActionDescriptor
|
||||||
|
|
||||||
|
**Type**: derived action classification entry
|
||||||
|
**Source**: existing Filament action definitions on the target page
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `actionKey` | string | Action name such as `capture`, `refresh_review`, or `archive` |
|
||||||
|
| `label` | string | Visible operator-facing label |
|
||||||
|
| `actionKind` | string | `navigation`, `mutation`, `external_link`, `lifecycle`, or `danger` |
|
||||||
|
| `placement` | string | `primary_visible`, `secondary_grouped`, `contextual`, or `danger_grouped` |
|
||||||
|
| `requiresConfirmation` | boolean | Mirrors existing destructive or governance friction |
|
||||||
|
| `usesUiEnforcement` | boolean | Whether the action is wrapped with a central enforcement helper |
|
||||||
|
| `capabilityKey` | string or null | Canonical capability requirement when applicable |
|
||||||
|
| `writesAuditLog` | boolean | Whether the underlying mutation writes audit truth |
|
||||||
|
| `operationScope` | string | `TenantPilot only`, `Microsoft tenant`, `simulation only`, or `read-only` |
|
||||||
|
|
||||||
|
### ContextualNavigationContract
|
||||||
|
|
||||||
|
**Type**: derived related-navigation placement entry
|
||||||
|
**Source**: existing related-navigation resolver output or page-local related links
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `entryKey` | string | Stable identifier for a related destination |
|
||||||
|
| `label` | string | Operator-facing related-link label |
|
||||||
|
| `targetUrl` | string or null | Existing target destination |
|
||||||
|
| `sourceSection` | string | `summary`, `related_context`, `field_context`, or `status_context` |
|
||||||
|
| `isAvailable` | boolean | Mirrors existing helper availability logic |
|
||||||
|
| `leaksScopeIfMisplaced` | boolean | True when placing this in the wrong layer could imply broader access |
|
||||||
|
|
||||||
|
### WorkflowHeavyHeaderGroup
|
||||||
|
|
||||||
|
**Type**: derived grouped-action contract for special-type pages
|
||||||
|
**Source**: explicit exception handling for `ViewTenant`
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `surfaceKey` | string | Expected to map to the special-type surface |
|
||||||
|
| `groupLabel` | string | Visible grouped-action label, usually `Actions` |
|
||||||
|
| `orderedBuckets` | array<string> | Ordered buckets such as `external_links`, `verification`, `setup`, and `lifecycle`; pure navigation remains contextual outside the header |
|
||||||
|
| `visiblePrimaryActionKey` | string or null | Optional visible primary action when a dominant next step exists |
|
||||||
|
| `requiresExplicitException` | boolean | Always true for workflow-heavy special types |
|
||||||
|
|
||||||
|
### HeaderRegressionExpectation
|
||||||
|
|
||||||
|
**Type**: guard and test expectation entry
|
||||||
|
**Source**: Spec 192 regression-protection requirements
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| `surfaceKey` | string | The page under regression protection |
|
||||||
|
| `maxVisiblePrimaryActions` | integer | `1` for standard pages, `0..1` for special types, `0..1` for references as documented |
|
||||||
|
| `requiresGroupedSecondaryActions` | boolean | Whether secondaries must be grouped |
|
||||||
|
| `allowsPrimaryNavigation` | boolean | Usually false for remediated standard pages |
|
||||||
|
| `requiresDangerSeparation` | boolean | True when the page contains destructive or governance-sensitive actions |
|
||||||
|
| `requiresExplicitExceptionReason` | boolean | True for special-type pages |
|
||||||
|
| `browserSmokeRequired` | boolean | True for remediation-required pages, the explicit special-type exception, and the compliant reference baseline set |
|
||||||
|
|
||||||
|
## Resolution Rules
|
||||||
|
|
||||||
|
### Standard-page rules
|
||||||
|
|
||||||
|
1. A remediation-required standard record/detail/edit page resolves to at most one `primary_visible` action.
|
||||||
|
2. Pure navigation actions resolve to `contextual`, not to `primary_visible` or header-grouped placement.
|
||||||
|
3. Rare or administrative mutations resolve to `secondary_grouped`.
|
||||||
|
4. Destructive or governance-sensitive actions resolve to `danger_grouped` and keep confirmation.
|
||||||
|
|
||||||
|
### State-sensitive primary-action rules
|
||||||
|
|
||||||
|
- `baseline_profile_view` resolves `capture` as primary when no consumable snapshot exists.
|
||||||
|
- `baseline_profile_view` resolves `compareNow` as primary when a consumable snapshot exists.
|
||||||
|
- `tenant_review_view` resolves one of `refresh_review`, `publish_review`, or `export_executive_pack` as primary based on lifecycle state.
|
||||||
|
- `finding_exception_view` may resolve `renew_exception` as primary only when renewal is valid and visible.
|
||||||
|
- `tenant_edit` does not introduce a second page-header primary because save/cancel remain the true edit-surface primary affordance.
|
||||||
|
|
||||||
|
### Special-type rules
|
||||||
|
|
||||||
|
1. `tenant_view` may expose zero visible primaries when no single dominant next step exists.
|
||||||
|
2. If `tenant_view` exposes one visible primary, all remaining actions still stay grouped and internally ordered.
|
||||||
|
3. `tenant_view` must always carry an explicit exception reason in the inventory and regression expectations.
|
||||||
|
|
||||||
|
### Reference-page rules
|
||||||
|
|
||||||
|
1. A compliant reference page may keep a single contextual related-record link or single primary safe action without further restructuring.
|
||||||
|
2. Reference pages must not be rebuilt only to mimic the remediated pages.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- One `HeaderSurfaceInventoryEntry` maps to one `RecordHeaderLayoutState`.
|
||||||
|
- One `RecordHeaderLayoutState` contains many `HeaderActionDescriptor` entries.
|
||||||
|
- A page may contain zero or many `ContextualNavigationContract` entries.
|
||||||
|
- Only special-type pages use a `WorkflowHeavyHeaderGroup`.
|
||||||
|
- Every in-scope page must map to one `HeaderRegressionExpectation`.
|
||||||
|
|
||||||
|
## Safety Rules
|
||||||
|
|
||||||
|
- No derived header model may widen tenant or workspace visibility beyond existing route and helper semantics.
|
||||||
|
- No action may lose `UiEnforcement`, confirmation, audit, or `OperationRun` behavior when it changes placement.
|
||||||
|
- No grouped secondary structure may become an undocumented exception or empty placeholder.
|
||||||
|
- No regression expectation may silently exempt a page; every exception must be explicit and justified.
|
||||||
325
specs/192-record-header-discipline/plan.md
Normal file
325
specs/192-record-header-discipline/plan.md
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
# Implementation Plan: Record Page Header Discipline & Contextual Navigation
|
||||||
|
|
||||||
|
**Branch**: `192-record-header-discipline` | **Date**: 2026-04-11 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/spec.md`
|
||||||
|
|
||||||
|
**Note**: This plan keeps the work inside the existing Filament v5 / Livewire v4 resource pages, existing related-navigation helpers, and the existing action-surface guard infrastructure. It explicitly avoids introducing a new header-action framework.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Codify one bounded header-discipline contract for classic record/detail/edit pages in the admin panel. Reuse existing Filament header actions, `ActionGroup`, `UiEnforcement`, and `RelatedNavigationResolver` patterns to inventory all in-scope surfaces, remediate the five standard pages that currently have noisy headers, preserve already-clean reference pages, explicitly document `ViewTenant` as a workflow-heavy special type, and extend the existing action-surface and browser regression layers so new header sprawl does not re-enter the repo.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `UiEnforcement`, `RelatedNavigationResolver`, `ActionSurfaceValidator`, and page-local Filament action builders
|
||||||
|
**Storage**: PostgreSQL through existing workspace-owned and tenant-owned resource models; no schema change planned
|
||||||
|
**Testing**: Pest feature tests, existing guard tests, and browser smoke tests run through Laravel Sail
|
||||||
|
**Target Platform**: Laravel monolith web application under `apps/platform`, with workspace/admin routes under `/admin`, tenant-context routes under `/admin/t/{tenant}/...`, and no panel expansion planned
|
||||||
|
**Project Type**: web application
|
||||||
|
**Performance Goals**: Preserve the 5-second scan rule on record pages, keep all affected pages DB-only at render time, avoid new polling or asset work, and prevent header cleanup from adding extra query churn or remote calls
|
||||||
|
**Constraints**: No new action framework, no new persistence, no route or panel changes, no authorization-plane changes, no new status language, no silent special-type exemptions, and no expansion of Spec 133 body-composition requirements beyond current scope
|
||||||
|
**Scale/Scope**: 14 in-scope record/detail/edit surfaces, 5 remediation-required standard pages, 1 explicit workflow-heavy special-type exception, 2 minor-alignment audits, 6 compliant/no-op reference pages, and focused guard plus feature plus browser regression coverage
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design and still passing.*
|
||||||
|
|
||||||
|
| Principle | Pre-Research | Post-Design | Notes |
|
||||||
|
|-----------|--------------|-------------|-------|
|
||||||
|
| Inventory-first / snapshots-second | PASS | PASS | The feature does not alter inventory or snapshot truth; it only reorganizes page headers. |
|
||||||
|
| Read/write separation | PASS | PASS | Existing mutations keep their current confirmation, audit, and test behavior. No new writes are introduced. |
|
||||||
|
| Graph contract path | N/A | N/A | No new Microsoft Graph call path or contract-registry change is planned. |
|
||||||
|
| Deterministic capabilities | PASS | PASS | Capability checks remain in canonical registries and `UiEnforcement`; regrouping actions does not change entitlement logic. |
|
||||||
|
| Workspace + tenant isolation | PASS | PASS | Existing route scopes and related-navigation availability rules remain authoritative. |
|
||||||
|
| RBAC-UX authorization semantics | PASS | PASS | Non-members remain `404`, in-scope capability denial remains `403`, and server-side authorization stays unchanged. |
|
||||||
|
| Run observability / Ops-UX | PASS | PASS | Underlying long-running actions such as capture, compare, refresh, and verification keep their existing `OperationRun` semantics. |
|
||||||
|
| Data minimization | PASS | PASS | No new persistence, caches, or header-state artifacts are introduced. |
|
||||||
|
| Proportionality / anti-bloat | PASS | PASS | The work stays inside existing pages and guard infrastructure instead of creating a new framework. |
|
||||||
|
| UI semantics / few layers | PASS | PASS | The feature relies on direct action placement and classification rather than a new presenter or semantic layer. |
|
||||||
|
| Filament-native UI | PASS | PASS | Native Filament header actions and `ActionGroup` remain the implementation path. |
|
||||||
|
| Surface taxonomy / HDR-001 | PASS | PASS | The plan explicitly classifies every in-scope page and documents the one workflow-heavy special type. |
|
||||||
|
| Filament v5 / Livewire v4 compliance | PASS | PASS | All touched pages remain inside the existing Filament v5 + Livewire v4 stack. |
|
||||||
|
| Provider registration location | PASS | PASS | No provider change is needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. |
|
||||||
|
| Global search hard rule | PASS | PASS | No new globally searchable resource is introduced; touched resources already have View/Edit pages where needed. |
|
||||||
|
| Destructive action safety | PASS | PASS | Existing destructive or governance-changing actions keep `->requiresConfirmation()` and stay authorization-gated. |
|
||||||
|
| Asset strategy | PASS | PASS | No new assets or lazy-load registrations are needed; existing deploy handling of `cd apps/platform && php artisan filament:assets` remains unchanged. |
|
||||||
|
|
||||||
|
## Filament-Specific Compliance Notes
|
||||||
|
|
||||||
|
- **Livewire v4.0+ compliance**: The plan remains on Filament v5 + Livewire v4 and introduces no legacy or mixed-version API usage.
|
||||||
|
- **Provider registration location**: No panel or provider changes are required; Laravel 11+ panel providers remain registered in `bootstrap/providers.php`.
|
||||||
|
- **Global search**: The feature does not add a new globally searchable resource. Touched resources continue to satisfy the Filament hard rule because they already have View and/or Edit pages; search behavior is otherwise unchanged.
|
||||||
|
- **Destructive actions**: `Expire snapshot`, `Revoke exception`, `Archive review`, tenant lifecycle actions, backup lifecycle actions, and provider credential danger actions remain routed through `Action::make(...)->action(...)` with `->requiresConfirmation()` and existing authorization.
|
||||||
|
- **Asset strategy**: No new global or on-demand asset registration is planned. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||||
|
- **Testing plan**: Extend the existing action-surface guard layer, add focused Livewire/Pest tests for the remediated pages and the explicit special type, and add a browser smoke suite that proves visible hierarchy on remediated headers.
|
||||||
|
|
||||||
|
## Phase 0 Research
|
||||||
|
|
||||||
|
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/research.md`.
|
||||||
|
|
||||||
|
Key decisions:
|
||||||
|
|
||||||
|
- Reuse existing page-local action builders, `UiEnforcement`, `ActionGroup`, and `RelatedNavigationResolver` instead of introducing a header-action framework.
|
||||||
|
- Move pure navigation to contextual placement outside the header instead of equal-weight header placement.
|
||||||
|
- Treat `ViewTenant` as an explicit workflow-heavy special-type exception rather than a standard record page.
|
||||||
|
- Preserve already-clean pages as reference patterns instead of cosmetically normalizing them.
|
||||||
|
- Build regression protection on top of the existing `ActionSurfaceValidator`, focused page tests, and browser smoke patterns.
|
||||||
|
|
||||||
|
## Phase 1 Design
|
||||||
|
|
||||||
|
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/192-record-header-discipline/`:
|
||||||
|
|
||||||
|
- `research.md`: decisions and rejected alternatives for bounded header discipline
|
||||||
|
- `data-model.md`: derived header-surface inventory, render contract, and regression expectation models
|
||||||
|
- `contracts/record-header-discipline.logical.openapi.yaml`: internal logical contract for standard-page headers, the special-type exception, and regression expectations
|
||||||
|
- `quickstart.md`: implementation and verification sequence for the feature
|
||||||
|
|
||||||
|
Design highlights:
|
||||||
|
|
||||||
|
- Keep all classification and render rules derived, not persisted.
|
||||||
|
- Represent each in-scope surface through one explicit inventory entry and one explicit regression expectation.
|
||||||
|
- Keep state-sensitive primary-action decisions local to the existing pages rather than moving them into a shared runtime resolver.
|
||||||
|
- Treat `ViewTenant` as the only explicit special type and require an exception reason in the regression layer.
|
||||||
|
- Extend the existing guard system rather than creating a new validation framework.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
Planned command:
|
||||||
|
|
||||||
|
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
This feature does not introduce a new technology stack, but the required agent-context refresh still runs to keep the planning workflow complete.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/192-record-header-discipline/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── spec.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── record-header-discipline.logical.openapi.yaml
|
||||||
|
└── checklists/
|
||||||
|
└── requirements.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ ├── BaselineProfileResource.php # MODIFY
|
||||||
|
│ │ ├── BaselineProfileResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewBaselineProfile.php # MODIFY
|
||||||
|
│ │ ├── EvidenceSnapshotResource.php # MODIFY
|
||||||
|
│ │ ├── EvidenceSnapshotResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewEvidenceSnapshot.php # MODIFY
|
||||||
|
│ │ ├── FindingExceptionResource.php # MODIFY
|
||||||
|
│ │ ├── FindingExceptionResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewFindingException.php # MODIFY
|
||||||
|
│ │ ├── TenantReviewResource.php # MODIFY
|
||||||
|
│ │ ├── TenantReviewResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewTenantReview.php # MODIFY
|
||||||
|
│ │ ├── TenantResource.php # MODIFY
|
||||||
|
│ │ ├── TenantResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ ├── EditTenant.php # MODIFY
|
||||||
|
│ │ │ └── ViewTenant.php # MODIFY (special type ordering only)
|
||||||
|
│ │ ├── ProviderConnectionResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewProviderConnection.php # AUDIT / possible minor alignment
|
||||||
|
│ │ ├── FindingResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewFinding.php # AUDIT / possible minor alignment
|
||||||
|
│ │ ├── BackupSetResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewBackupSet.php # REFERENCE only
|
||||||
|
│ │ ├── BaselineSnapshotResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewBaselineSnapshot.php # REFERENCE only
|
||||||
|
│ │ ├── ReviewPackResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewReviewPack.php # REFERENCE only
|
||||||
|
│ │ ├── AlertDestinationResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewAlertDestination.php # REFERENCE only
|
||||||
|
│ │ ├── PolicyVersionResource/
|
||||||
|
│ │ │ └── Pages/
|
||||||
|
│ │ │ └── ViewPolicyVersion.php # REFERENCE only
|
||||||
|
│ │ └── Workspaces/
|
||||||
|
│ │ └── Pages/
|
||||||
|
│ │ └── ViewWorkspace.php # REFERENCE only
|
||||||
|
│ ├── Support/
|
||||||
|
│ │ ├── Navigation/
|
||||||
|
│ │ │ └── RelatedNavigationResolver.php # REUSE
|
||||||
|
│ │ ├── Rbac/
|
||||||
|
│ │ │ └── UiEnforcement.php # REUSE
|
||||||
|
│ │ └── Ui/
|
||||||
|
│ │ └── ActionSurface/
|
||||||
|
│ │ ├── ActionSurfaceValidator.php # MODIFY
|
||||||
|
│ │ ├── ActionSurfaceExemptions.php # MODIFY
|
||||||
|
│ │ ├── ActionSurfaceProfileDefinition.php # POSSIBLE MODIFY
|
||||||
|
│ │ └── Enums/
|
||||||
|
│ │ └── ActionSurfaceProfile.php # POSSIBLE MODIFY
|
||||||
|
├── resources/
|
||||||
|
│ └── views/
|
||||||
|
│ └── filament/
|
||||||
|
│ └── infolists/
|
||||||
|
│ └── entries/
|
||||||
|
│ └── tenant-review-summary.blade.php # MODIFY
|
||||||
|
└── tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Guards/
|
||||||
|
│ │ ├── ActionSurfaceContractTest.php # MODIFY
|
||||||
|
│ │ ├── ActionSurfaceValidatorTest.php # MODIFY
|
||||||
|
│ │ └── Spec192RecordPageHeaderDisciplineGuardTest.php # NEW
|
||||||
|
│ └── Filament/
|
||||||
|
│ ├── BaselineProfileCaptureStartSurfaceTest.php # MODIFY or REUSE
|
||||||
|
│ ├── BaselineProfileCompareStartSurfaceTest.php # MODIFY or REUSE
|
||||||
|
│ ├── TenantViewHeaderUiEnforcementTest.php # MODIFY or REUSE
|
||||||
|
│ ├── FindingExceptionHeaderDisciplineTest.php # NEW
|
||||||
|
│ ├── TenantReviewHeaderDisciplineTest.php # NEW
|
||||||
|
│ └── EditTenantHeaderDisciplineTest.php # NEW
|
||||||
|
└── Browser/
|
||||||
|
├── Spec174EvidenceFreshnessPublicationTrustSmokeTest.php # REUSE for patterns
|
||||||
|
├── Spec190BaselineCompareMatrixSmokeTest.php # REUSE for patterns
|
||||||
|
└── Spec192RecordPageHeaderDisciplineSmokeTest.php # NEW
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional reused test files referenced in `tasks.md`, such as `EvidenceSnapshotResourceTest.php`, `TenantReviewUiContractTest.php`, and RBAC regression suites, remain in scope even when they are not repeated in the summary tree above.
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the work entirely inside the existing Laravel/Filament monolith under `apps/platform`. Modify only the affected resource classes, page classes, the tenant-review contextual Blade partial, the existing action-surface validation layer, and focused tests. Do not create a new support framework beyond the minimum needed to extend the existing guard patterns.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| Cross-page header taxonomy and explicit exception catalog (BLOAT-001 trigger) | The feature must distinguish standard record pages, reference pages, minor-alignment pages, and the single workflow-heavy exception in a way CI can validate. | Pure page-local cleanup would reduce immediate noise but would not prevent future drift or document why `ViewTenant` is intentionally different. |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Several classic record/detail/edit pages still present navigation, routine mutations, and danger as flat peers, slowing interpretation and weakening action hierarchy.
|
||||||
|
- **Existing structure is insufficient because**: The constitution now carries HDR-001, but the repo lacks a concrete inventory, a bounded classification model, and a regression hook that can distinguish standard pages from an allowed special type.
|
||||||
|
- **Narrowest correct implementation**: Keep all changes inside existing page classes and the existing action-surface validation layer, classify only the explicitly named pages, remediate only the pages that need it, and document exactly one special-type exception.
|
||||||
|
- **Ownership cost created**: A small amount of guard configuration, a few focused page tests, one smoke suite, and ongoing review discipline for future record pages.
|
||||||
|
- **Alternative intentionally rejected**: A new header-action framework, resolver, or interface layer was rejected because the current repo already has enough primitives to implement the discipline directly.
|
||||||
|
- **Release truth**: current-release operator clarity and action-surface discipline
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase A — Codify the inventory and the regression contract
|
||||||
|
|
||||||
|
Goal: turn the spec inventory into an enforceable project-level contract without introducing a new framework.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- Extend the existing action-surface validation and exemption layer with Spec 192 surface expectations.
|
||||||
|
- Encode the explicit workflow-heavy exception for `ViewTenant`.
|
||||||
|
- Record which pages are remediation-required, which are audit-only, and which are compliant references.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Add `Spec192RecordPageHeaderDisciplineGuardTest.php`.
|
||||||
|
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` with Spec 192 expectations.
|
||||||
|
|
||||||
|
### Phase B — Remediate the highest-noise standard record pages
|
||||||
|
|
||||||
|
Goal: implement the clearest header-discipline wins first on the pages with the most obvious peer-action sprawl.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- Refactor `ViewBaselineProfile` to expose one state-sensitive primary action and move navigation plus secondary actions out of the flat primary lane.
|
||||||
|
- Refactor `ViewTenantReview` so only one lifecycle action stays primary and the rest become grouped or contextual.
|
||||||
|
- Keep all existing action semantics, notifications, `OperationRun` links, confirmations, and authorization unchanged.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Reuse or extend existing baseline profile feature tests.
|
||||||
|
- Add runtime assertions for visible primary action count, grouped secondary placement, and preserved authorization behavior.
|
||||||
|
|
||||||
|
### Phase C — Remediate the remaining standard record and edit surfaces
|
||||||
|
|
||||||
|
Goal: finish the standard-page cleanup with lower-complexity but still important record surfaces.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- Refactor `ViewEvidenceSnapshot` to keep one central next step and separate lifecycle danger from related navigation.
|
||||||
|
- Refactor `ViewFindingException` so navigation becomes secondary while governance lifecycle remains clear.
|
||||||
|
- Refactor `EditTenant` so the header no longer competes with the edit task.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Add focused feature tests for `EvidenceSnapshot`, `FindingException`, and `EditTenant`.
|
||||||
|
- Preserve existing capability gating and confirmation behavior in all assertions.
|
||||||
|
|
||||||
|
### Phase D — Tighten the explicit exception and audit-only pages
|
||||||
|
|
||||||
|
Goal: make the special type explicit and ensure audit-only pages stay calm.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- Move pure navigation on `ViewTenant` into contextual placement outside the header and reorder grouped header actions by explicit buckets: external links, verification, setup, and lifecycle.
|
||||||
|
- Audit `ViewProviderConnection` and `ViewFinding` for minor alignment only and change them only if they still present real header-noise issues.
|
||||||
|
- Confirm `ViewBaselineSnapshot`, `ViewBackupSet`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace` remain compliant references.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Extend `TenantViewHeaderUiEnforcementTest.php` or add a dedicated special-type feature test.
|
||||||
|
- Cover the explicit exception reason in the guard layer.
|
||||||
|
|
||||||
|
### Phase E — Browser verification and final regression protection
|
||||||
|
|
||||||
|
Goal: prove the new hierarchy in a real browser and keep CI from accepting future header sprawl.
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
|
||||||
|
- Add `Spec192RecordPageHeaderDisciplineSmokeTest.php` covering the remediated pages, the workflow-heavy exception, and a no-regression baseline over the compliant reference set.
|
||||||
|
- Ensure the guard layer fails on multiple competing primaries, missing grouped secondary structure where required, or silent exceptions.
|
||||||
|
- Re-run formatting and the focused Sail test pack.
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
- Browser smoke coverage for visible hierarchy and no JavaScript errors.
|
||||||
|
- Focused guard and page-level tests for each remediated or exceptional surface.
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Impact | Likelihood | Mitigation |
|
||||||
|
|------|--------|------------|------------|
|
||||||
|
| Cleanup grows into a new action framework | Medium | Low | Keep all changes inside existing page classes and the current action-surface guard layer. |
|
||||||
|
| `More` groups become junk drawers | Medium | Medium | Treat internal order as part of the contract and test it on the special-type and remediated pages. |
|
||||||
|
| State-driven primary action is chosen incorrectly | High | Medium | Add focused runtime assertions for BaselineProfile and TenantReview state transitions. |
|
||||||
|
| `ViewTenant` silently bypasses the standard-page rule | Medium | Medium | Encode the special-type exception in the guard layer with an explicit reason and browser coverage. |
|
||||||
|
| Reference pages get unnecessary churn | Medium | Low | Keep a documented compliant-reference set and use it as a regression baseline. |
|
||||||
|
|
||||||
|
## Test Strategy
|
||||||
|
|
||||||
|
- Extend `ActionSurfaceContractTest.php` and `ActionSurfaceValidatorTest.php` so Spec 192 becomes an explicit CI-enforced rule rather than a manual review note.
|
||||||
|
- Add `Spec192RecordPageHeaderDisciplineGuardTest.php` to validate remediation-required pages, the explicit special type, and any whitelisted references.
|
||||||
|
- Reuse existing baseline profile tests where possible and add focused feature tests for `EvidenceSnapshot`, `FindingException`, `TenantReview`, and `EditTenant` where no dedicated header-discipline tests exist yet.
|
||||||
|
- Extend `TenantViewHeaderUiEnforcementTest.php` or add a dedicated special-type test so grouped ordering and exception semantics stay covered.
|
||||||
|
- Add `Spec192RecordPageHeaderDisciplineSmokeTest.php` using the existing browser-smoke infrastructure and fixture traits already used by Spec 174 and Spec 190, including a no-regression pass over the compliant reference set.
|
||||||
|
- Add explicit regression assertions that this feature does not force Spec 133 body-layout rollout and does not expand confirmation depth, reason capture, or provider-dispatch semantics.
|
||||||
|
- Run the focused Sail verification commands from `quickstart.md`, then run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check result: PASS.
|
||||||
|
|
||||||
|
- Livewire v4.0+ compliance remains intact because all touched surfaces stay inside the existing Filament v5 + Livewire v4 stack.
|
||||||
|
- Provider registration remains unchanged in `bootstrap/providers.php`.
|
||||||
|
- No new globally searchable resource is introduced; touched resources already have View and/or Edit pages where relevant.
|
||||||
|
- Destructive actions remain confirmation-gated and authorization-gated.
|
||||||
|
- No new asset strategy is required; deploy handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||||
|
- The testing plan covers the remediated standard pages, the explicit workflow-heavy exception, and the project-level regression guard for future record-page header drift.
|
||||||
88
specs/192-record-header-discipline/quickstart.md
Normal file
88
specs/192-record-header-discipline/quickstart.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Quickstart: Record Page Header Discipline & Contextual Navigation
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Bring the in-scope record/detail/edit surfaces under one bounded header-action discipline: one clear next step on standard pages, contextual navigation near related content and outside the header, grouped secondary actions for non-navigation actions, separated danger, and one explicit workflow-heavy exception.
|
||||||
|
|
||||||
|
## Implementation Sequence
|
||||||
|
|
||||||
|
1. Confirm the in-scope inventory in code.
|
||||||
|
- Map each in-scope page class to the Spec 192 classification.
|
||||||
|
- Confirm which pages are remediation-required, minor-alignment only, compliant reference, or workflow-heavy special type.
|
||||||
|
|
||||||
|
2. Remediate the standard pages first.
|
||||||
|
- Refactor `ViewBaselineProfile` to expose only one visible primary action based on snapshot readiness.
|
||||||
|
- Refactor `ViewEvidenceSnapshot` so run and review-pack navigation move to contextual placement outside the header and `Expire snapshot` remains separated.
|
||||||
|
- Refactor `ViewFindingException` so navigation moves to contextual placement outside the header, `Renew exception` may remain primary, and `Revoke exception` stays isolated.
|
||||||
|
- Refactor `ViewTenantReview` so only one lifecycle action is primary, navigation becomes contextual outside the header, and infrequent lifecycle actions stay grouped.
|
||||||
|
- Refactor `EditTenant` so the header stops competing with the edit task and view/onboarding links move into contextual tenant-meta placement.
|
||||||
|
|
||||||
|
3. Tighten the explicit exception and minor-alignment pages.
|
||||||
|
- Audit `ViewTenant` as the workflow-heavy special type and order its grouped actions deliberately.
|
||||||
|
- Review `ViewProviderConnection` and `ViewFinding` for minor alignment only, and change them only if the audit proves real header noise.
|
||||||
|
- Confirm that `ViewBaselineSnapshot`, `ViewBackupSet`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace` remain valid references.
|
||||||
|
|
||||||
|
4. Add regression protection.
|
||||||
|
- Extend the existing action-surface guard or exemption mapping with Spec 192 expectations.
|
||||||
|
- Add focused Livewire/Pest page tests for remediated surfaces.
|
||||||
|
- Add one browser smoke suite covering the remediated pages, the explicit special-type exception, and a no-regression baseline over the compliant reference set.
|
||||||
|
- Add explicit regression checks that this feature does not force Spec 133 body-layout rollout and does not expand confirmation depth, reason capture, or provider-dispatch semantics.
|
||||||
|
|
||||||
|
5. Run focused verification.
|
||||||
|
- Run the guard tests, the remediated page tests, the browser smoke suite, and formatting through Sail.
|
||||||
|
|
||||||
|
## Suggested Source Files
|
||||||
|
|
||||||
|
- `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`
|
||||||
|
- `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||||
|
- `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||||
|
|
||||||
|
## Suggested Test Files
|
||||||
|
|
||||||
|
- `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
|
||||||
|
- `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`
|
||||||
|
- `apps/platform/tests/Browser/Spec174EvidenceFreshnessPublicationTrustSmokeTest.php`
|
||||||
|
- `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||||
|
- `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
|
||||||
|
## Minimum Verification Commands
|
||||||
|
|
||||||
|
Run all commands through Sail from `apps/platform`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceValidatorTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php
|
||||||
|
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Acceptance Checklist
|
||||||
|
|
||||||
|
1. Open each remediated standard page and confirm the header shows at most one visible primary action.
|
||||||
|
2. Confirm pure navigation is no longer presented as an equal-weight peer to the primary mutation on remediated pages and now lives in contextual placement outside the header.
|
||||||
|
3. Confirm rare administrative actions live in a grouped secondary structure instead of a flat peer row.
|
||||||
|
4. Confirm destructive or governance-sensitive actions remain visually separated and keep confirmation.
|
||||||
|
5. Confirm the tenant edit page still reads as an edit surface first.
|
||||||
|
6. Confirm the tenant admin resource view stays grouped and ordered as an explicit workflow-heavy exception.
|
||||||
|
7. Confirm compliant reference pages do not regress or receive unnecessary cosmetic churn.
|
||||||
|
8. Confirm browser smoke checks show no JavaScript errors on the remediated pages, the workflow-heavy exception page, and the compliant reference baseline pages.
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
- No migration is expected.
|
||||||
|
- No new provider registration is expected; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||||
|
- No new asset registration is expected. Existing deploy handling of `cd apps/platform && php artisan filament:assets` remains sufficient.
|
||||||
94
specs/192-record-header-discipline/research.md
Normal file
94
specs/192-record-header-discipline/research.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# Research: Record Page Header Discipline & Contextual Navigation
|
||||||
|
|
||||||
|
## Decision: Reuse existing page-local action builders, `UiEnforcement`, `ActionGroup`, and related-navigation helpers instead of adding a new header-action framework
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
The codebase already has the right implementation primitives for this cleanup: page-private action builders, `UiEnforcement` for per-action RBAC handling, `ActionGroup` for secondary grouping, and `RelatedNavigationResolver` for contextual navigation. The spec needs discipline and regression protection, not a new registry, interface, or placement engine.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Add a `HeaderActionResolver` or `HeaderActionRegistry`: rejected because the repo already has several coordination abstractions and this feature does not justify another one.
|
||||||
|
- Add an `ActionPlacement` enum or interface hierarchy: rejected because the placement decisions remain page- and state-sensitive and would ossify too early.
|
||||||
|
|
||||||
|
## Decision: Keep contextual navigation close to the relevant content instead of leaving it in the flat header lane
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
Several touched pages already use related-navigation helpers or related-context rendering. The narrowest fix is to move pure navigation out of equal-weight header placement and into summary, field, badge, status, or related-context placement outside the header, using the existing related-navigation patterns rather than inventing new local UI concepts.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Keep `Open ...` and `View ...` actions in the primary header but restyle them: rejected because the problem is semantic weight, not only button color.
|
||||||
|
- Remove related navigation entirely: rejected because the navigation is still useful; it just belongs closer to its context.
|
||||||
|
|
||||||
|
## Decision: Treat the tenant admin resource view as an explicit workflow-heavy special-type exception
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
`ViewTenant` is not a simple record detail. It already acts as a workflow hub for verification, RBAC refresh, onboarding recovery, provider entry points, and lifecycle actions. The correct move is to keep it grouped and internally ordered, document it as a special type, and prevent it from silently bypassing the standard-page rule.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Force `ViewTenant` into the same single-next-step shape as simple record pages: rejected because it would misrepresent a multi-purpose operational hub.
|
||||||
|
- Leave `ViewTenant` undocumented as an exception: rejected because silent exceptions cause future drift and inconsistent review standards.
|
||||||
|
|
||||||
|
## Decision: Use existing calm pages as reference patterns and preserve no-op surfaces
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
The repo already contains pages that model the intended calmness well enough for this spec: `ViewBackupSet`, `ViewBaselineSnapshot`, `ViewReviewPack`, `ViewAlertDestination`, `ViewPolicyVersion`, and `ViewWorkspace`. These pages should be preserved unless a minor alignment issue is found, because the goal is discipline, not cosmetic uniformity.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Rebuild every in-scope page to the same visible pattern: rejected because it would create churn without additional operator value.
|
||||||
|
- Ignore no-op pages and only document the problem pages: rejected because the spec requires an explicit project-wide classification matrix.
|
||||||
|
|
||||||
|
## Decision: Drive standard-page remediation with page-local state and existing action methods
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
The heaviest remediations, especially `ViewBaselineProfile` and `ViewTenantReview`, already express their action logic through page-private methods or explicit action blocks. The cleanest implementation is to keep that local state logic and only change placement and grouping. This preserves existing authorization, notifications, run links, and confirmations.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Rebuild header logic around a shared presenter object: rejected because the same state machine is not yet shared across enough pages to justify a new layer.
|
||||||
|
- Push all actions into a single generic `More` dropdown: rejected because the spec requires structured grouping and a visible next step, not a junk drawer.
|
||||||
|
|
||||||
|
## Decision: Build regression protection on top of the existing action-surface guard and focused page tests
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
The repo already has `ActionSurfaceValidator`, `ActionSurfaceContractTest`, `ActionSurfaceValidatorTest`, `FilamentTableStandardsGuardTest`, focused Livewire page tests, and browser smoke coverage. The narrowest regression strategy is to extend those existing layers with Spec 192 expectations: a whitelisted header-discipline guard, page-level visibility assertions on the remediated screens, and one browser smoke path for visible hierarchy.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Add a new standalone framework for header-discipline validation: rejected because the existing `ActionSurfaceValidator` and guard test style already provide the right enforcement hook.
|
||||||
|
- Rely on manual review only: rejected because header-sprawl regressions are exactly the kind of drift CI should catch.
|
||||||
|
|
||||||
|
## Decision: Keep testing layered but lightweight
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
Three test layers are enough for this feature:
|
||||||
|
|
||||||
|
- static or discovery-based guard coverage for the classification and header-discipline contract,
|
||||||
|
- focused Livewire/Pest page tests for state-driven primary-action visibility and grouped-secondary behavior,
|
||||||
|
- one browser smoke suite proving the visible hierarchy on remediated pages, the explicit special-type exception, and a no-regression baseline over the compliant reference set.
|
||||||
|
|
||||||
|
This matches existing repo patterns and avoids over-testing presentation indirection.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Browser-test every permutation of every page: rejected because it would be expensive and redundant with feature tests.
|
||||||
|
- Add only guard tests: rejected because page-level state transitions still need runtime assertions.
|
||||||
|
|
||||||
|
## Decision: No new asset or provider work is needed
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
The cleanup stays entirely within existing Filament v5 header action surfaces. No panel providers, custom assets, or lazy-loaded scripts are required. Existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- Introduce custom header components or new assets for grouped actions: rejected because native Filament header actions and `ActionGroup` already satisfy the need.
|
||||||
341
specs/192-record-header-discipline/spec.md
Normal file
341
specs/192-record-header-discipline/spec.md
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
# Feature Specification: Record Page Header Discipline & Contextual Navigation
|
||||||
|
|
||||||
|
**Feature Branch**: `192-record-header-discipline`
|
||||||
|
**Created**: 2026-04-11
|
||||||
|
**Status**: Proposed
|
||||||
|
**Input**: User description: "Spec 192 - Record Page Header Discipline & Contextual Navigation"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Several classic admin record/detail/edit pages still expose flat header button rows where navigation, routine mutation, infrequent administration, and danger all compete at the same visual weight.
|
||||||
|
- **Today's failure**: Operators reach the right pages, but the header often does not signal the next best step. Navigation occupies prime action space, multiple mutations compete visibly, and rare or destructive actions appear too close to routine actions.
|
||||||
|
- **User-visible improvement**: Standard record pages become calmer and easier to scan. One next step becomes obvious, contextual navigation moves closer to the content it belongs to, and risky or rare actions stop cluttering the primary header lane.
|
||||||
|
- **Smallest enterprise-capable version**: Classify all in-scope record/detail/edit surfaces, remediate the clearly problematic standard pages with one shared header discipline, explicitly catalog the one workflow-heavy exception, and add lightweight regression protection.
|
||||||
|
- **Explicit non-goals**: No new global action framework for every surface class, no monitoring or workbench action cleanup, no new confirmation-depth policy, no provider-dispatch redesign, and no forced Spec 133 body-layout rollout onto simple pages.
|
||||||
|
- **Permanent complexity imported**: A narrow cross-page header-discipline contract, an explicit surface-classification matrix, a documented special-type exception, and focused regression coverage for record-page header sprawl.
|
||||||
|
- **Why now**: The constitution now contains HDR-001, and the repo already has both good and bad examples. Without a concrete inventory and rule rollout, drift will continue page by page.
|
||||||
|
- **Why not local**: Isolated page-by-page cleanup would reduce noise on one page at a time but would not create a stable repo-wide rule for when navigation belongs in context, when secondary actions must be grouped, or when a page deserves a special-type exception.
|
||||||
|
- **Approval class**: Cleanup
|
||||||
|
- **Red flags triggered**: Cross-domain UI rule risk if this grows into a generalized action framework. Defense: the spec stays limited to classic record/detail/edit surfaces, forbids a new engine, and explicitly excludes other surface classes.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: canonical-view
|
||||||
|
- **Primary Routes**:
|
||||||
|
- Existing BaselineProfile resource view route
|
||||||
|
- Existing EvidenceSnapshot, FindingException, and TenantReview resource view routes
|
||||||
|
- Existing Tenant resource view and edit routes in the tenant admin plane
|
||||||
|
- Existing ProviderConnection and Finding resource view routes
|
||||||
|
- Existing ReviewPack, AlertDestination, PolicyVersion, Workspace resource view routes
|
||||||
|
- Existing BaselineSnapshot and BackupSet resource view routes
|
||||||
|
- **Data Ownership**:
|
||||||
|
- Workspace-owned records touched by this spec remain workspace-owned: BaselineProfile, BaselineSnapshot, AlertDestination, PolicyVersion, and Workspace views.
|
||||||
|
- Tenant-owned records touched by this spec remain tenant-owned: EvidenceSnapshot, Finding, FindingException, TenantReview, Tenant, ProviderConnection, ReviewPack, and BackupSet views.
|
||||||
|
- This spec introduces no new tables, persisted entities, route semantics, or record truth. It changes only action hierarchy, placement, and classification on existing pages.
|
||||||
|
- **RBAC**:
|
||||||
|
- Existing workspace membership plus capability checks continue to govern workspace-owned pages.
|
||||||
|
- Existing tenant membership plus capability checks continue to govern tenant-owned pages.
|
||||||
|
- Header regrouping does not change authorization semantics: non-members remain `404`, members lacking a capability remain `403`, and destructive actions retain confirmation plus server-side authorization.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: This feature adds no new filter behavior. Tenant-scoped record pages remain bound to the active tenant context, while workspace-owned record pages remain workspace-scoped even if a tenant was previously active. Moving navigation out of the primary header must not broaden scope or imply cross-tenant search.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Any contextual link that survives the header cleanup must still be built through existing related-navigation and capability-aware helpers. Inaccessible related records remain suppressed, non-members remain deny-as-not-found, and moving an action from primary header placement to contextual or grouped placement must not reveal a destination that was previously inaccessible.
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Surface Type | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Baseline profile detail | Workspace record detail | Existing BaselineProfile inspect flow into the view page | allowed | Summary context and grouped secondary header actions after remediation | none | Existing BaselineProfile collection route | Existing BaselineProfile view route | Workspace, active snapshot state, visible assigned-tenant count | Baseline profile | Whether a consumable snapshot exists and whether capture or compare is the next step | remediation required |
|
||||||
|
| Evidence snapshot detail | Tenant record detail | Existing EvidenceSnapshot inspect flow into the view page | allowed | Related-context links and grouped secondary header actions | Separated lifecycle danger action | Existing EvidenceSnapshot collection route | Existing EvidenceSnapshot view route | Tenant, freshness, expiry, latest review-pack relationship | Evidence snapshot | Whether the snapshot is current, reusable, or needs refresh/expiry handling | remediation required |
|
||||||
|
| Finding exception detail | Tenant governance detail | Existing FindingException inspect flow into the view page | allowed | Related finding and approval-queue links move to contextual placement outside the header | Revocation remains isolated danger | Existing FindingException collection or approval-queue route | Existing FindingException view route | Tenant, validity state, owner, review due state | Finding exception | Whether the exception remains valid and what governance step is next | remediation required |
|
||||||
|
| Tenant review detail | Tenant record detail | Existing TenantReview inspect flow into the view page | allowed | Related export/evidence/run links move to contextual placement outside the header, while infrequent lifecycle actions stay grouped secondary | Archive remains separated inside secondary danger placement | Existing TenantReview collection route | Existing TenantReview view route | Tenant, review status, evidence/export linkage, operation context | Tenant review | Which lifecycle step is next and whether the review is ready to refresh, publish, or export | remediation required |
|
||||||
|
| Tenant edit surface | Tenant edit record | Existing Tenant edit entry from tenant list or tenant detail | allowed | Related view/context links move to contextual placement outside the header | Archive or restore stays separate from ordinary edit context | Existing Tenant collection route | Existing Tenant edit route | Tenant lifecycle, workspace context, related onboarding status | Tenant | That this surface is for editing and not for multi-purpose workflow dispatch | remediation required |
|
||||||
|
| Tenant detail (tenant admin resource view) | Workflow-heavy tenant detail hub | Existing Tenant inspect flow into the resource view page | allowed | Contextual navigation remains outside the header, while internal grouped header actions remain mandatory for external links, verification, setup, and lifecycle work | Lifecycle actions remain isolated inside grouped danger placement | Existing Tenant collection route | Existing Tenant resource view route | Tenant lifecycle, verification state, RBAC health, recent operations, onboarding context | Tenant | What setup, verification, or lifecycle step currently deserves attention | workflow-heavy special-type exception |
|
||||||
|
| Provider connection detail | Tenant configuration detail | Existing ProviderConnection inspect flow into the view page | allowed | Secondary admin actions stay in a deliberate grouped override-management structure | Dangerous credential actions stay inside separated danger slot within the group | Existing ProviderConnection collection route | Existing ProviderConnection view route | Tenant, provider, connection type, consent and verification state | Provider connection | Whether the connection is usable and which override or consent step is relevant | minor alignment only |
|
||||||
|
| Finding detail | Tenant record detail | Existing Finding inspect flow into the view page | allowed | Back-link, related-context link, and approval-queue navigation stay secondary or grouped | Workflow danger stays inside the workflow group | Existing Finding collection route | Existing Finding view route | Tenant, severity or health context, related approval state | Finding | What the finding means and where the next governance or remediation path lives | minor alignment only |
|
||||||
|
| Review pack detail | Tenant export detail | Existing ReviewPack inspect flow into the view page | allowed | Regenerate remains secondary to the pack outcome | none | Existing ReviewPack collection route | Existing ReviewPack view route | Tenant, pack status, export options, readiness | Review pack | Whether the pack is ready for download | compliant / no-op reference |
|
||||||
|
| Alert destination detail | Workspace configuration detail | Existing AlertDestination inspect flow into the view page | allowed | Deep-link navigation stays secondary | none | Existing AlertDestination collection route | Existing AlertDestination view route | Workspace, destination type, enabled state, last-test state | Alert destination | Whether delivery is enabled and whether the last test succeeded | compliant / no-op reference |
|
||||||
|
| Policy version detail | Workspace detail | Existing PolicyVersion inspect flow into the view page | allowed | Single related-record navigation remains contextual | none | Existing PolicyVersion collection route | Existing PolicyVersion view route | Workspace, version identity, policy relationship | Policy version | What version is being inspected and what record it belongs to | compliant / no-op reference |
|
||||||
|
| Workspace resource detail | Workspace detail | Existing Workspace inspect flow into the resource view page | allowed | No secondary header cluster required | none | Existing Workspace collection route | Existing Workspace resource view route | Workspace identity and manage capability state | Workspace | Which workspace is being viewed and whether it can be edited | compliant / no-op reference |
|
||||||
|
| Baseline snapshot detail | Workspace detail | Existing BaselineSnapshot inspect flow into the view page | allowed | Single related-record navigation remains contextual | none | Existing BaselineSnapshot collection route | Existing BaselineSnapshot view route | Workspace, source profile, snapshot recency | Baseline snapshot | What snapshot is current and what related record it belongs to | compliant / no-op reference |
|
||||||
|
| Backup set detail | Tenant recovery detail | Existing BackupSet inspect flow into the view page | allowed | Related-record navigation plus grouped secondary mutations | Grouped restore/archive/force-delete danger structure already exists | Existing BackupSet collection route | Existing BackupSet view route | Tenant, archive state, restore relevance | Backup set | Whether the backup set is active, archived, or eligible for restore | compliant / no-op reference |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Baseline profile detail | Workspace operator | Standard record detail | Is this baseline ready to capture or compare, and what is the next step? | Snapshot readiness, capture mode, visible assignment scope | Run IDs, rollout reason details | readiness, snapshot freshness | `simulation only` for compare; existing workspace capture path for capture | `Capture baseline` or `Compare now` | none |
|
||||||
|
| Evidence snapshot detail | Tenant operator | Standard record detail | Is this evidence current enough, and do I need to refresh or inspect related outputs? | Snapshot status, expiry, related pack/run availability | Internal run identifiers | freshness, lifecycle | Existing evidence refresh scope only | `Refresh evidence` when applicable | `Expire snapshot` |
|
||||||
|
| Finding exception detail | Tenant manager | Governance record detail | Is this exception still valid, and do I need to renew or revoke it? | Current validity, owner, review due, linked finding context | Detailed evidence references | validity, governance lifecycle | `TenantPilot only` | `Renew exception` when applicable | `Revoke exception` |
|
||||||
|
| Tenant review detail | Tenant manager | Standard record detail | What is the next lifecycle step for this review? | Review status, evidence/export linkage, current operation context | Low-level run metadata | lifecycle, readiness, freshness | `TenantPilot only` for publish/archive; existing export path for pack generation | One of `Refresh review`, `Publish review`, or `Export executive pack` depending on state | `Archive review` |
|
||||||
|
| Tenant edit surface | Tenant manager or owner | Edit surface | Can I update this tenant safely without losing context? | Editable tenant identity, lifecycle state, related onboarding context | Technical provider details remain outside the primary edit task | lifecycle | `TenantPilot only` | Save the edit form | `Archive` or `Restore` when available |
|
||||||
|
| Tenant detail (tenant admin resource view) | Tenant operator or manager | Workflow-heavy detail hub | What tenant setup, verification, or lifecycle step needs attention right now? | Verification report, recent operations, RBAC health, onboarding state | Raw run detail and lower-level provider metadata | lifecycle, verification readiness, RBAC health | Mixed existing tenant-operation scopes | `Verify configuration` only if it is the clearly dominant next step; otherwise grouped actions only | `Archive` or `Restore` |
|
||||||
|
| Provider connection detail | Tenant manager | Configuration detail | Is this connection healthy, and what provider-management step is next? | Consent, connection type, override state | Credential-specific technical detail | consent, verification, lifecycle | Existing provider-management scopes | `Edit` or `Grant admin consent` only if one clearly dominates | Credential delete and override reversion remain grouped danger |
|
||||||
|
| Finding detail | Tenant operator | Standard record detail | What does this finding mean, and where should I go next? | Finding summary, related record context, governance path | Low-level payload detail | governance state, health or severity | Existing finding workflow scope only | Existing workflow primary, if any, stays inside governed group | Existing workflow danger only |
|
||||||
|
| Review pack detail | Tenant operator | Export detail | Is this pack ready to use? | Status, download readiness, pack options summary | Raw generation metadata | readiness, lifecycle | Existing export scope only | `Download` | none |
|
||||||
|
| Alert destination detail | Workspace operator | Configuration detail | Is this destination working? | Destination type, enabled state, last-test result | Delivery log drilldown context | enablement, delivery health | Existing alert-test scope only | `Send test message` | none |
|
||||||
|
| Policy version detail | Workspace operator | Reference detail | Which version am I looking at, and what record should I open next? | Version identity and parent policy context | Raw payload footer content | version lifecycle | read-only | Related-record open action only | none |
|
||||||
|
| Workspace resource detail | Workspace owner or manager | Standard record detail | What workspace is this, and can I manage it? | Workspace identity and top-level manage affordance | Low-level metadata | none beyond manage eligibility | `TenantPilot only` | `Edit` | none |
|
||||||
|
| Baseline snapshot detail | Workspace operator | Reference detail | What snapshot is this and what should I open next? | Snapshot identity, related profile context | Detailed snapshot payload content | recency, completeness | read-only | Related-record open action only | none |
|
||||||
|
| Backup set detail | Tenant manager | Recovery detail | Is this backup set active, archived, or ready to restore? | Archive state, related record context, restore relevance | Deep backup item detail | lifecycle, restore readiness | Existing backup mutation scope only | Related-record open action stays secondary; restore remains grouped | `Archive` and `Force delete` remain grouped danger |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: no
|
||||||
|
- **New enum/state/reason family?**: no
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: yes
|
||||||
|
- **Current operator problem**: Classic record pages are inconsistent about what belongs in the primary header lane, so operators see avoidable noise and weak hierarchy on pages that should be fast to scan.
|
||||||
|
- **Existing structure is insufficient because**: HDR-001 exists at the constitutional level, but the repo still lacks a concrete inventory, a bounded standard-record rule set, and an explicit way to justify a workflow-heavy exception without page-local improvisation.
|
||||||
|
- **Narrowest correct implementation**: Apply the rule only to the explicitly named record/detail/edit surfaces, remediate only the pages that need it, preserve already clean pages, and document a single special-type exception instead of inventing a framework.
|
||||||
|
- **Ownership cost**: Ongoing review discipline for header-action placement, a small regression-test burden, browser smoke maintenance for remediated pages, and explicit exception tracking.
|
||||||
|
- **Alternative intentionally rejected**: Purely local cleanup on the five obvious problem pages was rejected because it would reduce immediate noise but would not stop future drift or explain why some pages are compliant while one page is an allowed exception.
|
||||||
|
- **Release truth**: current-release operator clarity and action-surface discipline
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - See one next step on standard record pages (Priority: P1)
|
||||||
|
|
||||||
|
As an operator opening a standard record/detail/edit page, I want the header to show one clear next step instead of a horizontal row of competing buttons.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core workflow benefit. If the next step still competes with several peer actions, the feature has not solved the problem it targets.
|
||||||
|
|
||||||
|
**Independent Test**: Open each remediation-required standard page and verify that no more than one visible emphasized header action remains while routine navigation and rare actions move out of the flat primary lane.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a remediation-required standard record page, **When** the page renders after cleanup, **Then** it shows at most one visible primary header action.
|
||||||
|
2. **Given** a standard page whose next step depends on state, **When** the record state changes, **Then** the page promotes only the state-appropriate next action and keeps others secondary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Follow contextual navigation near the relevant content (Priority: P1)
|
||||||
|
|
||||||
|
As an operator, I want related navigation to live near the summary or context it belongs to, instead of taking the same visual weight as a mutation.
|
||||||
|
|
||||||
|
**Why this priority**: Contextual navigation is still important, but it should support the reading flow instead of dominating the header.
|
||||||
|
|
||||||
|
**Independent Test**: Open remediated pages that currently mix navigation and mutation in the header and confirm that pure navigation moves to related-context or other inline contextual placement outside the header.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a remediated standard page with related-record navigation, **When** the page renders, **Then** the navigation no longer appears as a peer to the main mutation and instead lives in contextual placement outside the header.
|
||||||
|
2. **Given** a page offers both navigation and a next-step mutation, **When** the operator scans the header, **Then** the mutation reads as the next step and the navigation reads as supporting context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keep rare and dangerous actions available without clutter (Priority: P2)
|
||||||
|
|
||||||
|
As an operator, I want infrequent administrative actions and destructive actions to remain available without visually competing with routine work.
|
||||||
|
|
||||||
|
**Why this priority**: The pages still need power-user actions, but those actions should not make every visit look risky or overloaded.
|
||||||
|
|
||||||
|
**Independent Test**: Open pages with lifecycle or dangerous actions and confirm that rare actions are grouped and danger remains visibly separated with existing confirmation behavior intact.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a remediated page includes rare administrative actions, **When** the page renders, **Then** those actions live in a deliberate secondary group instead of as peer buttons.
|
||||||
|
2. **Given** a remediated page includes destructive or governance-sensitive actions, **When** the page renders, **Then** those actions remain separated from safe routine actions and still require confirmation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Treat workflow-heavy pages as explicit exceptions (Priority: P3)
|
||||||
|
|
||||||
|
As a product reviewer, I want workflow-heavy record pages to be explicitly identified as exceptions so they stay disciplined without being forced into the wrong pattern.
|
||||||
|
|
||||||
|
**Why this priority**: The cleanup should not flatten important operational hubs into misleadingly simple pages.
|
||||||
|
|
||||||
|
**Independent Test**: Review the special-type page and verify that it is explicitly classified, internally structured, and not silently exempted.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the tenant admin resource view is a workflow-heavy hub, **When** the spec and implementation are reviewed, **Then** it is marked as a special-type exception with a documented internal action order.
|
||||||
|
2. **Given** a special-type page does not have one obvious next step, **When** it renders, **Then** it does not reintroduce a flat multi-button header row in the name of consistency.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- If a baseline profile has no consumable snapshot, `Capture baseline` remains the sole visible primary action and compare actions stay secondary or disabled.
|
||||||
|
- If a standard page has no related destination currently available, the cleanup must not insert a dead contextual-navigation placeholder just to satisfy consistency.
|
||||||
|
- If the current actor can view a record but cannot execute its mutation, the action may remain visible-but-disabled per existing RBAC rules, but it still must not become a competing primary if it is not executable.
|
||||||
|
- If a workflow-heavy page has no clearly dominant next step, it should keep grouped actions only rather than manufacturing a fake primary.
|
||||||
|
- If a compliant reference page already has one clean related action or one clean grouped danger structure, the spec must not force cosmetic churn.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature introduces no new Microsoft Graph contract, no new persisted truth, and no new queue model. It only reorganizes existing operator-facing action surfaces on existing record/detail/edit pages. Existing mutations keep their current preview, confirmation, audit, and run-observability behavior.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The spec adds only a narrow cross-page UI discipline plus an explicit exception vocabulary. It introduces no new persistence, no new state family, and no generalized action engine. The proportionality review above documents why a bounded cross-page rule is justified and why a broader framework is rejected.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** Existing operations such as baseline capture, baseline compare, evidence refresh, tenant review refresh, verification, and provider-management actions continue to use their current `OperationRun`, toast, and audit contracts. This spec does not create a new run type or alter existing run summary semantics.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The feature spans workspace/admin and tenant-context surfaces but does not change authorization logic. Non-members remain `404`, members lacking required capabilities remain `403`, grouped or relocated actions still enforce server-side authorization, and destructive actions continue to require confirmation. At least one positive and one negative regression test must confirm that regrouping actions does not loosen access.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior changes.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** The cleanup does not introduce new badge semantics. Existing status and health badges remain centralized and unchanged.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The feature must use native Filament header actions, `ActionGroup`, and existing shared UI primitives. It must avoid a page-local button framework or ad-hoc badge language. The only approved exception is keeping the tenant admin resource view as a workflow-heavy surface with explicit grouped action ordering.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Action labels must stay domain-first and consistent as actions move between primary, contextual, and grouped placement. Target verbs include `Capture baseline`, `Compare now`, `Refresh review`, `Publish review`, `Verify configuration`, `Open …`, `Archive`, and `Restore`. Implementation-first labels must not become primary operator labels.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** This spec classifies each affected surface, defines the one inspect/open model already in use, preserves existing list inspect affordances, and reassigns secondary and destructive actions according to surface type. Standard record pages must expose one clear next step; explicit exceptions must be catalogued and justified.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** Default-visible header content must stay operator-first. Pure navigation becomes contextual content outside the header, diagnostics remain secondary, and dangerous actions keep existing confirmation and mutation-scope language. Workspace and tenant context must remain explicit through the same routes, related links, and action copy already in use.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** The feature introduces no new presenter or semantic layer. It uses direct action placement and classification rather than a new interpretation framework. Tests should prove business consequences of header discipline, not a thin abstraction.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract is satisfied for all remediated standard record/detail/edit pages after cleanup: one visible primary header action, no redundant flat navigation buttons, no empty action groups, and destructive actions in the correct secondary or danger placement. The tenant admin resource view is the only explicit exception and must document why grouped workflow actions remain appropriate.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature changes header-action hierarchy only. View pages continue to rely on their existing infolists or structured sections, and the tenant edit page keeps its edit-form save/cancel affordances as the primary edit path. The spec does not justify any regression toward disabled edit forms or naked-field layouts.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-192-001 Surface inventory**: The spec and implementation MUST maintain an explicit inventory of every in-scope record/detail/edit surface covered by this feature.
|
||||||
|
- **FR-192-002 Explicit classification**: Every in-scope surface MUST be assigned exactly one classification: `compliant / no-op reference`, `remediation required`, `minor alignment only`, or `workflow-heavy special-type exception`.
|
||||||
|
- **FR-192-003 Standard-page primary rule**: Every remediated standard record/detail/edit surface MUST expose at most one visible emphasized primary header action.
|
||||||
|
- **FR-192-004 Contextual navigation rule**: Pure related-record navigation MUST leave the flat primary header lane on remediated standard pages and move into contextual placement outside the header.
|
||||||
|
- **FR-192-005 Secondary grouping rule**: Infrequent, administrative, or secondary actions MUST move into deliberate grouped secondary placement rather than remaining as peer buttons.
|
||||||
|
- **FR-192-006 Group-order rule**: Secondary grouped actions MUST remain internally ordered so navigation, routine mutation, external links, and danger do not become an undifferentiated junk drawer.
|
||||||
|
- **FR-192-007 Danger separation rule**: Destructive, irreversible, or governance-sensitive actions MUST remain visibly separated from safe routine actions and keep their existing confirmation behavior.
|
||||||
|
- **FR-192-008 Baseline profile hierarchy**: On BaselineProfile detail, `Capture baseline` MUST be the only visible primary action when no consumable snapshot exists, and `Compare now` MUST be the only visible primary action when a consumable snapshot exists. `View snapshot` and `Open compare matrix` MUST move to contextual placement outside the header, while `Compare assigned tenants` and `Edit` become secondary.
|
||||||
|
- **FR-192-009 Evidence snapshot hierarchy**: On EvidenceSnapshot detail, only one central next action MAY remain visible. `Open operation` and `View review pack` MUST move to contextual placement outside the header, and `Expire snapshot` MUST remain a separated lifecycle danger action.
|
||||||
|
- **FR-192-010 Finding exception hierarchy**: On FindingException detail, related navigation MUST move to contextual placement outside the header, `Renew exception` MAY be primary when renewal is valid, and `Revoke exception` MUST remain separated as a danger-governance action.
|
||||||
|
- **FR-192-011 Tenant review hierarchy**: On TenantReview detail, only one dominant lifecycle action MAY remain primary based on review state. Refresh, publish, export, and related navigation MUST no longer appear as a flat row of peer buttons; pure navigation moves to contextual placement outside the header, and archive remains secondary danger.
|
||||||
|
- **FR-192-012 Tenant edit discipline**: EditTenant MUST remain edit-first. Save/cancel remain the primary edit affordance, while `View` and related onboarding move to contextual placement outside the header and lifecycle actions stay secondary and structurally separated.
|
||||||
|
- **FR-192-013 Workflow-heavy exception contract**: The tenant admin resource view MUST be explicitly marked as a workflow-heavy special type. It MAY expose one visible primary action only when a clear next step dominates; otherwise its actions remain grouped with deliberate internal order.
|
||||||
|
- **FR-192-014 Minor-alignment review**: ViewProviderConnection and ViewFinding MUST be reviewed against the same discipline but changed only when a real header-noise issue exists.
|
||||||
|
- **FR-192-015 Reference preservation**: ViewBaselineSnapshot, ViewBackupSet, ViewReviewPack, ViewAlertDestination, ViewPolicyVersion, and the Workspace resource view MUST stay unchanged or receive only minimal alignment if they already satisfy the discipline.
|
||||||
|
- **FR-192-016 No body-layout expansion**: This feature MUST NOT force Spec 133 body-layout rollout onto simple view pages that are already structurally acceptable.
|
||||||
|
- **FR-192-017 No governance-friction expansion**: This feature MUST NOT widen confirmation depth, reason-capture rules, or provider-dispatch semantics beyond the behavior already owned by the underlying actions.
|
||||||
|
- **FR-192-018 Authorization continuity**: Moving, grouping, or relabeling actions MUST NOT change route scope, capability enforcement, deny-as-not-found behavior, or audit obligations.
|
||||||
|
- **FR-192-019 Vocabulary continuity**: Header labels, modal titles, notifications, and related helper copy MUST keep the same domain vocabulary when actions move between primary, contextual, and grouped placement.
|
||||||
|
- **FR-192-020 Regression guard**: The repo MUST add a lightweight project-wide guard that prevents new standard record pages from reintroducing multiple competing primary header actions, flat navigation-mutation mixes, or silent special-type exceptions.
|
||||||
|
- **FR-192-021 Browser verification**: Browser or UI smoke checks MUST cover all remediation-required pages, the explicit workflow-heavy exception, and a no-regression baseline over the compliant or no-op reference pages.
|
||||||
|
|
||||||
|
## Surface Decision Matrix
|
||||||
|
|
||||||
|
- **Remediation required**:
|
||||||
|
- BaselineProfile detail
|
||||||
|
- EvidenceSnapshot detail
|
||||||
|
- FindingException detail
|
||||||
|
- TenantReview detail
|
||||||
|
- EditTenant
|
||||||
|
- **Workflow-heavy special-type exception**:
|
||||||
|
- Tenant detail (tenant admin resource view)
|
||||||
|
- **Minor alignment only**:
|
||||||
|
- ProviderConnection detail
|
||||||
|
- Finding detail
|
||||||
|
- **Compliant / no-op reference**:
|
||||||
|
- BaselineSnapshot detail
|
||||||
|
- BackupSet detail
|
||||||
|
- ReviewPack detail
|
||||||
|
- AlertDestination detail
|
||||||
|
- PolicyVersion detail
|
||||||
|
- Workspace resource detail
|
||||||
|
|
||||||
|
## Target Outcomes by Key Surface
|
||||||
|
|
||||||
|
- **BaselineProfile detail**: The header presents one obvious next step. Snapshot and matrix navigation stop competing with compare or capture. Secondary compare variants and edit move into grouped secondary placement.
|
||||||
|
- **EvidenceSnapshot detail**: One next action stays visible. Run and review-pack navigation move to contextual placement outside the header. `Expire snapshot` remains clearly separated as lifecycle danger.
|
||||||
|
- **FindingException detail**: Governance lifecycle stops competing with related navigation. `Renew exception` and `Revoke exception` are no longer presented as flat equals.
|
||||||
|
- **TenantReview detail**: Review lifecycle becomes easier to scan. The page distinguishes viewing related outputs from refreshing, publishing, exporting, or archiving.
|
||||||
|
- **EditTenant**: The page reads as an edit surface first. View/context links move to contextual placement outside the header, and archive or restore stops competing with the edit task.
|
||||||
|
- **Tenant detail (tenant admin resource view)**: The page remains a workflow hub, but grouped header actions gain an explicit internal order separating external links, verification, setup, and lifecycle, while pure navigation moves into contextual placement outside the header.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Creating a new global action framework for every surface class
|
||||||
|
- Cleaning up monitoring, queue, or workbench surfaces
|
||||||
|
- Hardening confirmation-depth, reason-capture, or danger-vocabulary policy
|
||||||
|
- Changing dispatch, preflight, provider-start, or other backend operation semantics
|
||||||
|
- Extending Spec 133 body composition to every view page by default
|
||||||
|
- Cosmetic flattening of already-clean pages for the sake of uniformity alone
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Spec 133 remains a body-composition reference only and is not the rollout vehicle for this feature.
|
||||||
|
- Existing pages such as BaselineSnapshot detail and BackupSet detail remain valid internal reference patterns for calm record-page headers.
|
||||||
|
- Existing server-side authorization, audit, and run-observability behavior is already correct for the underlying actions and will be preserved as actions move.
|
||||||
|
- The tenant admin resource view is the only currently known page in this scope that deserves an explicit workflow-heavy exception rather than a standard-record cleanup.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- HDR-001 in the constitution as the source rule for header action discipline
|
||||||
|
- Existing related-navigation and contextual-link helpers already used by several detail pages
|
||||||
|
- Existing Filament header action surfaces and `ActionGroup` patterns
|
||||||
|
- Existing browser and regression testing infrastructure for Filament surfaces
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- The cleanup could drift into a broader action-framework project if it is not kept strictly to classic record/detail/edit surfaces.
|
||||||
|
- A grouped secondary menu could become a junk drawer if internal order is not treated as part of the contract.
|
||||||
|
- Workflow-heavy pages could be flattened incorrectly if the exception rule is not explicit and narrow.
|
||||||
|
- Cosmetic overreach could create churn on already-compliant pages and dilute the value of the cleanup.
|
||||||
|
|
||||||
|
## Review Questions
|
||||||
|
|
||||||
|
- Does every remediated standard page now make the next likely step obvious?
|
||||||
|
- Has pure navigation moved closer to the content it belongs to instead of living in the primary header lane?
|
||||||
|
- Are rare and dangerous actions calmer without becoming hard to find?
|
||||||
|
- Is the tenant admin resource view clearly documented as a special type rather than a silent inconsistency?
|
||||||
|
- Have compliant reference pages been preserved instead of cosmetically rebuilt?
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| BaselineProfile detail | `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php` | `Capture baseline` or `Compare now` becomes the sole visible primary; `View snapshot` and `Open compare matrix` move to contextual placement outside the header, while `Compare assigned tenants` and `Edit` become grouped secondary actions | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Flat multi-button header is removed; contextual navigation leaves the primary lane | n/a | Existing compare and capture run/audit behavior unchanged | Remediation-required standard record page |
|
||||||
|
| EvidenceSnapshot detail | `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` | One visible primary action only; `Open operation` and `View review pack` move to contextual placement outside the header; `Expire snapshot` remains separated danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Header becomes `one next step + contextual links + separated danger` | n/a | Existing evidence refresh and expire behavior unchanged | Remediation-required standard record page |
|
||||||
|
| FindingException detail | `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php` | `Renew exception` may be primary; `Open finding` and `Open approval queue` move to contextual placement outside the header; `Revoke exception` remains separated danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Navigation and lifecycle actions are no longer flat peers | n/a | Existing exception-service audit behavior unchanged | Remediation-required standard record page |
|
||||||
|
| TenantReview detail | `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php` | One of `Refresh review`, `Publish review`, or `Export executive pack` may be primary depending on state; `Open operation`, `View executive pack`, and `View evidence snapshot` move to contextual placement outside the header; `More` retains infrequent actions with `Archive review` separated as danger | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Flat peer row of navigation plus lifecycle actions is removed | n/a | Existing review lifecycle and export behavior unchanged | Remediation-required standard record page |
|
||||||
|
| EditTenant | `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php` | Header no longer carries `View` or related onboarding navigation; those move into contextual tenant-meta placement outside the header, while lifecycle actions remain grouped or separated and do not compete with the edit task | Existing resource edit entry unchanged | Unchanged by this spec | none | unchanged | Header no longer competes with form submission | Save and cancel remain the primary edit affordance | Existing tenant lifecycle audit behavior unchanged | Remediation-required edit surface |
|
||||||
|
| Tenant detail (tenant admin resource view) | `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php` | Existing grouped `Actions` menu stays for external links, verification, setup, and lifecycle work, while pure navigation moves to contextual placement outside the header; `Verify configuration` may be lifted only as a sole visible primary if clearly justified | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No flat multi-button row is introduced to fake standardization | n/a | Existing tenant verification, RBAC refresh, and lifecycle audit behavior unchanged | Explicit workflow-heavy special-type exception |
|
||||||
|
| ProviderConnection detail | `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` | Audit whether `Grant admin consent` and `Edit` should remain peers; dedicated override actions stay grouped under one managed secondary structure | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | Only minor cleanup if the header still reads as two competing primaries | n/a | Existing dedicated-override audit behavior unchanged | Minor alignment only |
|
||||||
|
| Finding detail | `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php` | `Back to origin`, `Open related record`, and `Open approval queue` remain contextual outside the header; workflow actions stay inside the existing governed group | Existing resource inspect flow unchanged | Unchanged by this spec | Existing list behavior unchanged | unchanged | Only adjust if flat navigation still competes with the workflow group | n/a | Existing workflow audit behavior unchanged | Minor alignment only |
|
||||||
|
| ReviewPack detail | `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php` | `Download` remains the clear primary and `Regenerate` remains secondary | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing review-pack generation behavior unchanged | Compliant / no-op reference |
|
||||||
|
| AlertDestination detail | `apps/platform/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php` | `Send test message` stays primary and `View last delivery` stays secondary | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing alert-test behavior unchanged | Compliant / no-op reference |
|
||||||
|
| PolicyVersion detail | `apps/platform/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php` | Existing calm related-record access remains contextual and needs no expansion in this feature | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Read-only surface; no new audit behavior | Compliant / no-op reference |
|
||||||
|
| Workspace resource detail | `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php` | Single `Edit` action remains acceptable | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Existing workspace-manage behavior unchanged | Compliant / no-op reference |
|
||||||
|
| BaselineSnapshot detail | `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` | Existing calm related-record access remains contextual and needs no expansion in this feature | Existing resource inspect flow unchanged | Unchanged by this spec | none | unchanged | No structural header change expected | n/a | Read-only surface; no new audit behavior | Compliant / no-op reference |
|
||||||
|
| BackupSet detail | `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php` | Existing `More` structure with grouped restore and delete lifecycle actions remains acceptable | Existing resource inspect flow unchanged | Unchanged by this spec | Existing list behavior unchanged | unchanged | No structural header change expected unless grouped ordering needs small tightening | n/a | Existing restore/delete audit behavior unchanged | Compliant / no-op reference |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Record Header Discipline**: The cross-page contract for standard record/detail/edit pages: one visible primary action, contextual navigation near content, grouped secondary actions, and separated danger.
|
||||||
|
- **Surface Classification Matrix**: The explicit catalog assigning each in-scope surface to remediation required, minor alignment only, compliant/no-op reference, or workflow-heavy special-type exception.
|
||||||
|
- **Special-Type Exception**: The explicit allowance for a workflow-heavy record page to stay grouped and internally ordered without pretending to be a standard single-next-step detail page.
|
||||||
|
- **Contextual Navigation Slot**: The related-context placement that keeps navigation near the summary or section it belongs to instead of in a flat primary header row.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-192-001**: In acceptance review and smoke coverage, 100% of remediation-required standard record/detail/edit pages show no more than one visible primary header action.
|
||||||
|
- **SC-192-002**: 100% of in-scope surfaces are explicitly classified in the spec and implementation notes, and no affected page remains an undocumented exception.
|
||||||
|
- **SC-192-003**: Every remediated standard page relocates at least one pure navigation action out of the flat primary header lane into contextual placement outside the header.
|
||||||
|
- **SC-192-004**: During acceptance walkthroughs, reviewers can identify the next likely step on each remediated standard page within 5 seconds without scanning more than one header action cluster.
|
||||||
|
- **SC-192-005**: No compliant or no-op reference page receives a structural header rebuild unless a documented minor-alignment finding exists, and smoke coverage confirms no regression on that reference set.
|
||||||
|
- **SC-192-006**: The tenant admin resource view remains explicitly marked as a workflow-heavy special type and passes review without reverting to a flat peer-button header row.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
This feature is complete when:
|
||||||
|
|
||||||
|
- every in-scope record/detail/edit surface is classified explicitly,
|
||||||
|
- every remediated standard page shows at most one visible primary header action,
|
||||||
|
- contextual navigation has left the flat primary header lane on remediated standard pages,
|
||||||
|
- rare and administrative actions are grouped deliberately rather than left as peer buttons,
|
||||||
|
- destructive or governance-sensitive actions remain structurally separated,
|
||||||
|
- the tenant admin resource view is explicitly documented and treated as a workflow-heavy special type,
|
||||||
|
- compliant and no-op reference pages are preserved rather than cosmetically rebuilt,
|
||||||
|
- a lightweight regression guard exists for future record-page header changes,
|
||||||
|
- and browser smoke checks confirm the visible hierarchy on the remediated pages.
|
||||||
|
|
||||||
|
## Recommended Sequencing
|
||||||
|
|
||||||
|
- Spec 193 should handle monitoring and workbench action hierarchy, where different surface rules are needed.
|
||||||
|
- Spec 194 should handle governance-friction hardening, including confirmation depth, reason capture, and danger-language policy.
|
||||||
237
specs/192-record-header-discipline/tasks.md
Normal file
237
specs/192-record-header-discipline/tasks.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# Tasks: Record Page Header Discipline & Contextual Navigation
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/192-record-header-discipline/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/record-header-discipline.logical.openapi.yaml`
|
||||||
|
|
||||||
|
**Tests**: Tests are REQUIRED. Extend the existing guard layer, focused Pest feature coverage, and browser smoke coverage for the affected Filament pages.
|
||||||
|
**Operations**: This feature reuses existing action semantics only. No new `OperationRun` type, summary-count contract, or notification channel should be introduced.
|
||||||
|
**RBAC**: Existing workspace and tenant authorization semantics remain authoritative. Tasks must preserve non-member `404`, member-without-capability `403`, central capability registry usage, and per-action `UiEnforcement`.
|
||||||
|
**Filament v5 / Livewire v4**: All touched surfaces remain inside the existing Filament v5 + Livewire v4 stack.
|
||||||
|
**Provider Registration**: No panel or provider changes are planned; Laravel 11+ provider registration remains in `bootstrap/providers.php`.
|
||||||
|
**Global Search**: Touched resources already have their current View/Edit coverage and search settings; no new globally searchable resource is introduced.
|
||||||
|
**Destructive Actions**: Existing destructive or governance-changing actions must remain `->requiresConfirmation()` and authorization-gated after regrouping.
|
||||||
|
**Asset Strategy**: No new asset registration is planned; existing deployment handling of `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story so each story can be implemented and verified independently, while acknowledging that some stories touch the same page files and are therefore safer to land sequentially.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Acceptance Seams)
|
||||||
|
|
||||||
|
**Purpose**: Create the focused guard, feature, and browser test entry points used by the implementation work.
|
||||||
|
|
||||||
|
- [X] T001 Create the Spec 192 guard entry point in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`
|
||||||
|
- [X] T002 [P] Create the focused page-test entry points in `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
|
||||||
|
- [X] T003 [P] Create the browser smoke entry point and compliant-reference baseline cases in `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Focused verification entry points exist for guard, page-level, and browser-level work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Regression Contract)
|
||||||
|
|
||||||
|
**Purpose**: Encode the surface inventory, classification, and explicit exception model before touching page behavior.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T004 Encode the Spec 192 surface inventory, classifications, and explicit `ViewTenant` exception metadata in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php`
|
||||||
|
- [X] T005 [P] Extend the record-page header-discipline validation rules for standard pages and the workflow-heavy special type in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||||
|
- [X] T006 [P] Add foundational guard assertions for classified surfaces, explicit exceptions, and compliant references in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceValidatorTest.php`, and `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The repo can fail CI when a standard record page regresses into multiple competing primaries or when a special-type exception is left undocumented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - See One Next Step On Standard Record Pages (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Standard record/detail/edit pages expose one clear visible next step instead of a flat row of competing peer actions.
|
||||||
|
|
||||||
|
**Independent Test**: Open each remediation-required standard page and verify that it renders at most one visible primary header action while preserving existing authorization and action semantics.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T007 [P] [US1] Extend state-sensitive primary-action assertions for baseline profile detail in `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php` and `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`
|
||||||
|
- [X] T008 [P] [US1] Add one-primary-action assertions for evidence snapshot and finding exception detail in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php` and `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`
|
||||||
|
- [X] T009 [P] [US1] Add one-primary-action assertions for tenant review and tenant edit surfaces in `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T010 [US1] Refactor state-sensitive primary-action selection and grouped secondaries in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||||
|
- [X] T011 [US1] Refactor evidence snapshot header hierarchy so only one visible next-step action remains in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||||
|
- [X] T012 [US1] Refactor finding exception header hierarchy so renewal can be primary and revocation stays separated in `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||||
|
- [X] T013 [US1] Refactor tenant review header hierarchy to promote only one lifecycle primary action in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||||
|
- [X] T014 [US1] Refactor the tenant edit header so it stops competing with the form primary affordance in `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||||
|
- [X] T015 [US1] Run focused primary-action verification in `apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The five remediation-required standard pages expose no more than one visible primary header action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Follow Contextual Navigation Near The Relevant Content (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Pure navigation leaves the flat primary header lane and appears nearer to the summary, related context, or grouped secondary actions it belongs to.
|
||||||
|
|
||||||
|
**Independent Test**: Open remediated pages with related destinations and verify that navigation is no longer presented as an equal-weight primary peer to the main mutation.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T016 [P] [US2] Add contextual-navigation assertions for baseline profile and evidence snapshot detail in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
- [X] T017 [P] [US2] Add contextual-navigation assertions for finding exception, tenant review, and tenant edit surfaces in `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php`, and `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T018 [US2] Move active snapshot and compare-matrix navigation into contextual baseline-profile sections in `apps/platform/app/Filament/Resources/BaselineProfileResource.php` and `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||||
|
- [X] T019 [US2] Move operation and review-pack navigation into evidence snapshot summary or related-context sections in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php` and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||||
|
- [X] T020 [US2] Move finding and approval-queue navigation into finding-exception related-context placement in `apps/platform/app/Filament/Resources/FindingExceptionResource.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||||
|
- [X] T021 [US2] Move operation, executive-pack, and evidence navigation into tenant-review contextual summary surfaces in `apps/platform/app/Filament/Resources/TenantReviewResource.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, and `apps/platform/resources/views/filament/infolists/entries/tenant-review-summary.blade.php`
|
||||||
|
- [X] T022 [US2] Move tenant edit view and onboarding links into contextual tenant-meta placement outside the header in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`
|
||||||
|
- [X] T023 [US2] Run focused contextual-navigation verification in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Related navigation has left the flat primary header lane on the remediated standard pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Keep Rare And Dangerous Actions Available Without Clutter (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Rare administrative actions and destructive or governance-sensitive actions remain available without visually competing with routine work.
|
||||||
|
|
||||||
|
**Independent Test**: Open pages with grouped secondaries or danger actions and verify that rare actions live in grouped structures while danger stays visibly separated and confirmation-gated.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T024 [P] [US3] Extend grouped-secondary and danger-separation assertions for baseline profile, tenant review, and tenant edit in `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`, and `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`
|
||||||
|
- [X] T025 [P] [US3] Add grouped-secondary and danger assertions for evidence snapshot, finding exception, provider connection, and finding detail in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, `apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php`, `apps/platform/tests/Feature/Filament/FindingViewRbacEvidenceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T026 [US3] Group non-primary baseline profile actions into deliberate secondary and admin buckets in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`
|
||||||
|
- [X] T027 [US3] Separate lifecycle danger from safe secondaries on evidence snapshot and finding exception detail in `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php` and `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`
|
||||||
|
- [X] T028 [US3] Group tenant review lifecycle and export actions while keeping archive in a danger bucket in `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`
|
||||||
|
- [X] T029 [US3] Keep tenant edit lifecycle actions secondary and aligned with tenant lifecycle naming in `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php` and `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||||
|
- [X] T030 [US3] Audit provider connection and finding detail headers for real minor-alignment issues in `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php` and `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and only apply cleanup if the audit proves current header noise
|
||||||
|
- [X] T031 [US3] Run focused grouped-secondary and danger verification in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantLifecycleActionVisibilityTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, `apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php`, `apps/platform/tests/Feature/Filament/FindingViewRbacEvidenceTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: Rare and dangerous actions remain available, but no longer clutter standard record-page headers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: User Story 4 - Treat Workflow-Heavy Pages As Explicit Exceptions (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Workflow-heavy pages remain disciplined through explicit exception handling instead of silent non-conformance.
|
||||||
|
|
||||||
|
**Independent Test**: Review `ViewTenant` and verify that it is explicitly marked as a special type, internally ordered, and not silently exempted from the record-page rule.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [X] T032 [P] [US4] Extend workflow-heavy exception assertions for tenant detail in `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
- [X] T033 [P] [US4] Extend guard assertions so `ViewTenant` requires an explicit special-type reason in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [X] T034 [US4] Move pure tenant-detail navigation into contextual placement outside the header and reorder grouped header actions into explicit external-link, verification/setup, and lifecycle buckets in `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`
|
||||||
|
- [X] T035 [US4] Encode the workflow-heavy special-type reason and max-primary-action rule for tenant detail in `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceExemptions.php` and `apps/platform/app/Support/Ui/ActionSurface/ActionSurfaceValidator.php`
|
||||||
|
- [X] T036 [US4] Run focused special-type verification in `apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php`, `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The workflow-heavy exception is explicit, tested, and structurally disciplined.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Confirm compliant references, align operator copy, and run the focused verification pack.
|
||||||
|
|
||||||
|
- [X] T037 [P] Audit and explicitly confirm compliant reference pages remain no-op in `apps/platform/app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `apps/platform/app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php`, `apps/platform/app/Filament/Resources/ReviewPackResource/Pages/ViewReviewPack.php`, `apps/platform/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php`, `apps/platform/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`, and `apps/platform/app/Filament/Resources/Workspaces/Pages/ViewWorkspace.php`
|
||||||
|
- [X] T038 [P] Align `Verb + Object` labels and grouped-action copy in `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`, `apps/platform/app/Filament/Resources/FindingExceptionResource/Pages/ViewFindingException.php`, `apps/platform/app/Filament/Resources/TenantReviewResource/Pages/ViewTenantReview.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/EditTenant.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [X] T039 [P] Add or update compliant-reference and no-regression assertions in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||||
|
- [X] T040 [P] Extend `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php` with no-regression coverage for the compliant reference set named in Spec 192
|
||||||
|
- [X] T041 [P] Add explicit no-body-layout-expansion assertions for the remediated and reference views in `apps/platform/tests/Feature/Guards/Spec192RecordPageHeaderDisciplineGuardTest.php` and `apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php`
|
||||||
|
- [X] T042 [P] Add explicit no-governance-friction-expansion assertions that regrouping preserves confirmation depth and action semantics in `apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php`, `apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php`, `apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php`, and `apps/platform/tests/Feature/Rbac/EditTenantArchiveUiEnforcementTest.php`
|
||||||
|
- [X] T043 [P] Run the focused verification commands documented in `specs/192-record-header-discipline/quickstart.md`
|
||||||
|
- [X] T044 [P] Run formatting on touched Filament page and guard files from `apps/platform/` via `./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user-story work.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion and is the MVP slice.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion. It is independently testable, but it touches several of the same page files as US1, so it is safer to land after US1 unless work is split carefully.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion. It reuses the same page files as US1 and US2, so it is also safest after the primary and contextual hierarchy work is stable.
|
||||||
|
- **User Story 4 (Phase 6)**: Depends on Foundational completion and should land after the standard-page rule is clear, because it documents the explicit exception to that rule.
|
||||||
|
- **Polish (Phase 7)**: Depends on all desired story phases being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: Independent after Phase 2 and should be delivered first as the MVP.
|
||||||
|
- **US2**: Independent after Phase 2 from a behavior standpoint, but shares files with US1 and should usually follow it in the same branch.
|
||||||
|
- **US3**: Independent after Phase 2 from a behavior standpoint, but depends on the stabilized header hierarchy from US1 and US2 for low-risk implementation.
|
||||||
|
- **US4**: Independent after Phase 2 and focused on the explicit special-type exception for tenant detail.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Story-level tests should be written and made to fail before the implementation tasks for that story.
|
||||||
|
- Page-level behavior changes should preserve current authorization, confirmation, notification, and audit semantics.
|
||||||
|
- Each story should be verified through its focused test files before moving on.
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- T002 and T003 can run in parallel during Setup.
|
||||||
|
- T005 and T006 can run in parallel once the inventory in T004 exists.
|
||||||
|
- Within each story, the test tasks marked `[P]` can run in parallel because they touch separate files.
|
||||||
|
- US1, US2, and US3 share several of the same page classes, so their implementation tasks are not good parallel candidates even though the stories are independently testable.
|
||||||
|
- US4 can proceed in parallel with late US3 polish if one contributor is focused only on `ViewTenant`, the guard layer, and the smoke suite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch the focused header-hierarchy tests together:
|
||||||
|
Task: "Extend state-sensitive primary-action assertions for baseline profile detail in apps/platform/tests/Feature/Filament/BaselineProfileCaptureStartSurfaceTest.php and apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php"
|
||||||
|
Task: "Add one-primary-action assertions for evidence snapshot and finding exception detail in apps/platform/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php and apps/platform/tests/Feature/Filament/FindingExceptionHeaderDisciplineTest.php"
|
||||||
|
Task: "Add one-primary-action assertions for tenant review and tenant edit surfaces in apps/platform/tests/Feature/TenantReview/TenantReviewUiContractTest.php, apps/platform/tests/Feature/Filament/TenantReviewHeaderDisciplineTest.php, and apps/platform/tests/Feature/Filament/EditTenantHeaderDisciplineTest.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 4
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Split the explicit exception work across tests and implementation:
|
||||||
|
Task: "Extend workflow-heavy exception assertions for tenant detail in apps/platform/tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php, apps/platform/tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php, and apps/platform/tests/Browser/Spec192RecordPageHeaderDisciplineSmokeTest.php"
|
||||||
|
Task: "Move pure tenant-detail navigation into contextual placement outside the header and reorder grouped header actions into explicit external-link, verification/setup, and lifecycle buckets in apps/platform/app/Filament/Resources/TenantResource/Pages/ViewTenant.php"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. **STOP and VALIDATE**: Confirm the five remediation-required standard pages expose one clear next step.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Deliver US1 to remove competing primary actions.
|
||||||
|
2. Deliver US2 to move navigation closer to the content it belongs to.
|
||||||
|
3. Deliver US3 to calm rare and dangerous actions without hiding them.
|
||||||
|
4. Deliver US4 to make the workflow-heavy tenant detail exception explicit and disciplined.
|
||||||
|
5. Finish with compliant-reference confirmation, vocabulary cleanup, validation, and formatting.
|
||||||
|
|
||||||
|
### Validation Rule
|
||||||
|
|
||||||
|
1. Do not mark a story complete until its focused verification task passes.
|
||||||
|
2. Preserve existing `404` vs `403` behavior, confirmation requirements, and `OperationRun` semantics throughout implementation.
|
||||||
|
3. Treat the compliant-reference set as a regression baseline, not as a cosmetic rewrite target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` tasks touch different files and can be executed in parallel.
|
||||||
|
- User-story labels map directly to the prioritized stories in `spec.md`.
|
||||||
|
- This feature deliberately prefers existing Filament action builders and guard infrastructure over introducing a new header-action framework.
|
||||||
Loading…
Reference in New Issue
Block a user