feat: standardize enterprise detail pages (#162)
## Summary - introduce a shared enterprise-detail composition layer for Filament detail pages - migrate BackupSet, BaselineSnapshot, EntraGroup, and OperationRun detail screens to the shared summary-first layout - add regression and unit coverage for section hierarchy, related context, degraded states, and duplicate fact/badge presentation ## Scope - adds shared support classes under `app/Support/Ui/EnterpriseDetail` - adds shared enterprise detail Blade partials under `resources/views/filament/infolists/entries/enterprise-detail` - updates touched Filament resources/pages to use the shared detail shell - includes Spec 133 artifacts under `specs/133-detail-page-template` ## Notes - branch: `133-detail-page-template` - base: `dev` - commit: `fd294c7` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #162
This commit is contained in:
parent
8ee1174c8d
commit
d4fb886de0
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -61,6 +61,7 @@ ## Active Technologies
|
||||
- PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering)
|
||||
- PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation)
|
||||
- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver)
|
||||
- PostgreSQL via Laravel Sail; no schema change expected (133-detail-page-template)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -80,8 +81,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 133-detail-page-template: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 132-guid-context-resolver: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 131-cross-resource-navigation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 130-structured-snapshot-rendering: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -162,6 +163,23 @@ public function redactionIntegrityNote(): ?string
|
||||
return isset($this->run) ? RedactionIntegrity::noteForRun($this->run) : null;
|
||||
}
|
||||
|
||||
public function pollInterval(): ?string
|
||||
{
|
||||
if (! isset($this->run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->opsUxIsTabHidden === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filled($this->mountedActions ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RunDetailPolling::interval($this->run);
|
||||
}
|
||||
|
||||
public function content(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
|
||||
@ -7,6 +7,9 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
||||
use BackedEnum;
|
||||
@ -33,6 +36,16 @@ class WorkspaceOverview extends Page
|
||||
*/
|
||||
public array $overview = [];
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Workspace overview is a singleton landing page with no page-header actions.')
|
||||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace overview is already the canonical landing surface for the active workspace.')
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Workspace overview does not render record rows with secondary actions.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Workspace overview does not expose bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Workspace overview redirects or renders overview content instead of a list-style empty state.');
|
||||
}
|
||||
|
||||
public function mount(WorkspaceOverviewBuilder $builder): void
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
@ -25,6 +25,14 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -35,7 +43,6 @@
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Contracts\HasTable;
|
||||
@ -43,6 +50,7 @@
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use UnitEnum;
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
@ -55,6 +63,17 @@ class BackupSetResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Create backup set is available in the list header.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Restore, archive, and force delete remain grouped in the More menu on table rows.')
|
||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk archive, restore, and force delete stay grouped under More.')
|
||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Create backup set remains available from the empty state.')
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation and grouped mutation actions render in the view header.');
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
@ -519,29 +538,10 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Infolists\Components\TextEntry::make('name'),
|
||||
Infolists\Components\TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
|
||||
Infolists\Components\TextEntry::make('item_count')->label('Items'),
|
||||
Infolists\Components\TextEntry::make('created_by')->label('Created by'),
|
||||
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
|
||||
Infolists\Components\TextEntry::make('metadata')
|
||||
->label('Metadata')
|
||||
->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT))
|
||||
->copyable()
|
||||
->copyMessage('Metadata copied'),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
Infolists\Components\ViewEntry::make('related_context')
|
||||
Infolists\Components\ViewEntry::make('enterprise_detail')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (BackupSet $record): array => static::relatedContextEntries($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||
->state(fn (BackupSet $record): array => static::enterpriseDetailPage($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
@ -637,4 +637,94 @@ public static function createBackupSet(array $data): BackupSet
|
||||
includeScopeTags: $data['include_scope_tags'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
private static function enterpriseDetailPage(BackupSet $record): EnterpriseDetailPageData
|
||||
{
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::BackupSetStatus, $record->status);
|
||||
$metadata = is_array($record->metadata) ? $record->metadata : [];
|
||||
$metadataKeyCount = count($metadata);
|
||||
$relatedContext = static::relatedContextEntries($record);
|
||||
$isArchived = $record->trashed();
|
||||
|
||||
return EnterpriseDetailBuilder::make('backup_set', 'tenant')
|
||||
->header(new SummaryHeaderData(
|
||||
title: (string) $record->name,
|
||||
subtitle: 'Backup set #'.$record->getKey(),
|
||||
statusBadges: [
|
||||
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
$factory->statusBadge($isArchived ? 'Archived' : 'Active', $isArchived ? 'warning' : 'success'),
|
||||
],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
descriptionHint: 'Lifecycle status, recovery readiness, and related operations stay ahead of raw backup metadata.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'lifecycle_overview',
|
||||
kind: 'core_details',
|
||||
title: 'Lifecycle overview',
|
||||
items: [
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Items', $record->item_count),
|
||||
$factory->keyFact('Created by', $record->created_by),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => $relatedContext],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Recovery readiness',
|
||||
items: [
|
||||
$factory->keyFact('Backup state', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'timestamps',
|
||||
title: 'Timing',
|
||||
items: [
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Captured', static::formatDetailTimestamp($record->created_at)),
|
||||
],
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Technical detail',
|
||||
entries: [
|
||||
$factory->keyFact('Metadata keys', $metadataKeyCount),
|
||||
$factory->keyFact('Archived', $isArchived),
|
||||
],
|
||||
description: 'Low-signal backup metadata stays available here without taking over the recovery workflow.',
|
||||
view: $metadata !== [] ? 'filament.infolists.entries.snapshot-json' : null,
|
||||
viewData: ['payload' => $metadata],
|
||||
emptyState: $factory->emptyState('No backup metadata was recorded for this backup set.'),
|
||||
),
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof Carbon) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return $value->toDayDateTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBackupSet extends ViewRecord
|
||||
@ -14,7 +23,7 @@ class ViewBackupSet extends ViewRecord
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
$actions = [
|
||||
Action::make('primary_related')
|
||||
->label(fn (): string => app(RelatedNavigationResolver::class)
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->actionLabel ?? 'Open related record')
|
||||
@ -24,5 +33,153 @@ protected function getHeaderActions(): array
|
||||
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_BACKUP_SET, $this->getRecord())?->isAvailable() ?? false))
|
||||
->color('gray'),
|
||||
];
|
||||
|
||||
$mutationActions = [
|
||||
$this->restoreAction(),
|
||||
$this->archiveAction(),
|
||||
$this->forceDeleteAction(),
|
||||
];
|
||||
|
||||
$actions[] = ActionGroup::make($mutationActions)
|
||||
->label('More')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('gray');
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function restoreAction(): Action
|
||||
{
|
||||
$action = Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
|
||||
->action(function (AuditLogger $auditLogger): void {
|
||||
/** @var BackupSet $record */
|
||||
$record = $this->getRecord();
|
||||
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.restored',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set restored')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: Tenant::current()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function archiveAction(): Action
|
||||
{
|
||||
$action = Action::make('archive')
|
||||
->label('Archive')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && ! $this->getRecord()->trashed())
|
||||
->action(function (AuditLogger $auditLogger): void {
|
||||
/** @var BackupSet $record */
|
||||
$record = $this->getRecord();
|
||||
|
||||
$record->delete();
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]],
|
||||
);
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set archived')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
|
||||
private function forceDeleteAction(): Action
|
||||
{
|
||||
$action = Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (): bool => $this->getRecord() instanceof BackupSet && $this->getRecord()->trashed())
|
||||
->action(function (AuditLogger $auditLogger): void {
|
||||
/** @var BackupSet $record */
|
||||
$record = $this->getRecord();
|
||||
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
->title('Cannot force delete backup set')
|
||||
->body('Backup sets referenced by restore runs cannot be removed.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->tenant instanceof Tenant) {
|
||||
$auditLogger->log(
|
||||
tenant: $record->tenant,
|
||||
action: 'backup.force_deleted',
|
||||
resourceType: 'backup_set',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: ['metadata' => ['name' => $record->name]],
|
||||
);
|
||||
}
|
||||
|
||||
$record->items()->withTrashed()->forceDelete();
|
||||
$record->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Backup set permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply();
|
||||
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,7 +119,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'Snapshots are immutable; rows navigate directly to the detail page.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Snapshots are immutable; no bulk actions.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; snapshots appear after baseline captures.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Primary related navigation is surfaced in the view header.');
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
|
||||
@ -11,16 +11,12 @@
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\SnapshotRendering\BaselineSnapshotPresenter;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
use App\Support\Navigation\RelatedContextEntry;
|
||||
use App\Support\Navigation\RelatedNavigationResolver;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ViewBaselineSnapshot extends ViewRecord
|
||||
@ -30,7 +26,7 @@ class ViewBaselineSnapshot extends ViewRecord
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $presentedSnapshot = [];
|
||||
public array $enterpriseDetail = [];
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
@ -39,8 +35,11 @@ public function mount(int|string $record): void
|
||||
$snapshot = $this->getRecord();
|
||||
|
||||
if ($snapshot instanceof BaselineSnapshot) {
|
||||
$this->presentedSnapshot = app(BaselineSnapshotPresenter::class)
|
||||
->present($snapshot)
|
||||
$relatedContext = app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $snapshot);
|
||||
|
||||
$this->enterpriseDetail = app(BaselineSnapshotPresenter::class)
|
||||
->presentEnterpriseDetail($snapshot, $relatedContext)
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@ -75,89 +74,10 @@ public function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Snapshot')
|
||||
->schema([
|
||||
TextEntry::make('snapshot_id')
|
||||
->label('Snapshot')
|
||||
->state(function (): string {
|
||||
$snapshotId = data_get($this->presentedSnapshot, 'snapshot.snapshotId');
|
||||
|
||||
return is_numeric($snapshotId) ? '#'.$snapshotId : '—';
|
||||
}),
|
||||
TextEntry::make('baseline_profile_name')
|
||||
->label('Baseline')
|
||||
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.baselineProfileName', '—'))
|
||||
->placeholder('—'),
|
||||
TextEntry::make('captured_at')
|
||||
->label('Captured')
|
||||
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.capturedAt'))
|
||||
->dateTime()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('state_label')
|
||||
->label('State')
|
||||
->badge()
|
||||
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.stateLabel', 'Complete'))
|
||||
->color(fn (string $state): string => $state === 'Captured with gaps' ? 'warning' : 'success'),
|
||||
TextEntry::make('overall_fidelity')
|
||||
->label('Overall fidelity')
|
||||
->badge()
|
||||
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.overallFidelity'))
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BaselineSnapshotFidelity))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BaselineSnapshotFidelity))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BaselineSnapshotFidelity)),
|
||||
TextEntry::make('fidelity_summary')
|
||||
->label('Evidence mix')
|
||||
->state(fn (): string => data_get($this->presentedSnapshot, 'snapshot.fidelitySummary', 'Content 0, Meta 0')),
|
||||
TextEntry::make('overall_gap_count')
|
||||
->label('Evidence gaps')
|
||||
->state(fn (): int => (int) data_get($this->presentedSnapshot, 'snapshot.overallGapCount', 0)),
|
||||
TextEntry::make('snapshot_identity_hash')
|
||||
->label('Identity hash')
|
||||
->state(fn (): ?string => data_get($this->presentedSnapshot, 'snapshot.snapshotIdentityHash'))
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Section::make('Coverage summary')
|
||||
->schema([
|
||||
ViewEntry::make('summary_rows')
|
||||
ViewEntry::make('enterprise_detail')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.baseline-snapshot-summary-table')
|
||||
->state(fn (): array => data_get($this->presentedSnapshot, 'summaryRows', []))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (): array => app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_BASELINE_SNAPSHOT, $this->getRecord()))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Captured policy types')
|
||||
->schema([
|
||||
ViewEntry::make('groups')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.baseline-snapshot-groups')
|
||||
->state(fn (): array => data_get($this->presentedSnapshot, 'groups', []))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
Section::make('Technical detail')
|
||||
->schema([
|
||||
ViewEntry::make('technical_detail')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.baseline-snapshot-technical-detail')
|
||||
->state(fn (): array => data_get($this->presentedSnapshot, 'technicalDetail', []))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed()
|
||||
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||
->state(fn (): array => $this->enterpriseDetail)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -12,16 +12,19 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use BackedEnum;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use UnitEnum;
|
||||
|
||||
class EntraGroupResource extends Resource
|
||||
@ -38,12 +41,13 @@ class EntraGroupResource extends Resource
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Directory groups list intentionally has no header actions; sync is started from the sync-runs surface.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'No secondary row actions are provided on this read-only list.')
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for directory groups.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.');
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; groups appear after sync.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'No canonical related destination exists for directory groups yet.');
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
@ -55,40 +59,10 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Group')
|
||||
->schema([
|
||||
TextEntry::make('display_name')->label('Name'),
|
||||
TextEntry::make('entra_id')->label('Entra ID')->copyable(),
|
||||
TextEntry::make('type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
|
||||
TextEntry::make('security_enabled')
|
||||
->label('Security')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
TextEntry::make('mail_enabled')
|
||||
->label('Mail')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)),
|
||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Raw groupTypes')
|
||||
->schema([
|
||||
ViewEntry::make('group_types')
|
||||
ViewEntry::make('enterprise_detail')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EntraGroup $record) => $record->group_types ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||
->state(fn (EntraGroup $record): array => static::enterpriseDetailPage($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
@ -251,4 +225,105 @@ private static function groupTypeColor(string $type): string
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private static function enterpriseDetailPage(EntraGroup $record): EnterpriseDetailPageData
|
||||
{
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
|
||||
$groupType = static::groupType($record);
|
||||
$groupTypeLabel = static::groupTypeLabel($groupType);
|
||||
$groupTypeBadge = $factory->statusBadge($groupTypeLabel, static::groupTypeColor($groupType));
|
||||
$securityBadge = $factory->statusBadge(
|
||||
BadgeRenderer::label(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||
BadgeRenderer::color(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||
BadgeRenderer::icon(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||
BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)($record->security_enabled),
|
||||
);
|
||||
$mailBadge = $factory->statusBadge(
|
||||
BadgeRenderer::label(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||
BadgeRenderer::color(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||
BadgeRenderer::icon(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||
BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)($record->mail_enabled),
|
||||
);
|
||||
|
||||
$technicalPayload = [
|
||||
'entra_id' => $record->entra_id,
|
||||
'group_types' => is_array($record->group_types) ? $record->group_types : [],
|
||||
];
|
||||
|
||||
return EnterpriseDetailBuilder::make('entra_group', 'tenant')
|
||||
->header(new SummaryHeaderData(
|
||||
title: (string) $record->display_name,
|
||||
subtitle: 'Directory group #'.$record->getKey(),
|
||||
statusBadges: [$groupTypeBadge, $securityBadge, $mailBadge],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Type', $groupTypeLabel, badge: $groupTypeBadge),
|
||||
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
|
||||
$factory->keyFact('Security enabled', $record->security_enabled, badge: $securityBadge),
|
||||
$factory->keyFact('Mail enabled', $record->mail_enabled, badge: $mailBadge),
|
||||
],
|
||||
descriptionHint: 'Group identity and classification stay ahead of provider-oriented metadata.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'classification_overview',
|
||||
kind: 'core_details',
|
||||
title: 'Classification overview',
|
||||
items: [
|
||||
$factory->keyFact('Type', $groupTypeLabel, badge: $groupTypeBadge),
|
||||
$factory->keyFact('Security enabled', $record->security_enabled, badge: $securityBadge),
|
||||
$factory->keyFact('Mail enabled', $record->mail_enabled, badge: $mailBadge),
|
||||
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => []],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'summary',
|
||||
title: 'Directory identity',
|
||||
items: [
|
||||
$factory->keyFact('Display name', $record->display_name),
|
||||
$factory->keyFact('Entra ID', $record->entra_id),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'timestamps',
|
||||
title: 'Freshness',
|
||||
items: [
|
||||
$factory->keyFact('Last seen', static::formatDetailTimestamp($record->last_seen_at)),
|
||||
$factory->keyFact('Cached group types', count($technicalPayload['group_types'])),
|
||||
],
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Technical detail',
|
||||
entries: [
|
||||
$factory->keyFact('Entra ID', $record->entra_id),
|
||||
$factory->keyFact('Cached group types', count($technicalPayload['group_types'])),
|
||||
],
|
||||
description: 'Provider identifiers and raw group-type arrays stay secondary to group identity and classification.',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $technicalPayload],
|
||||
),
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof Carbon) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return $value->toDayDateTimeString();
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,9 +21,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -33,10 +31,8 @@
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
@ -81,9 +77,9 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
ActionSurfaceSlot::ListEmptyState,
|
||||
'Empty-state action is intentionally omitted; users can adjust filters/date range in-page.',
|
||||
)
|
||||
->exempt(
|
||||
->satisfy(
|
||||
ActionSurfaceSlot::DetailHeader,
|
||||
'Tenantless detail view is informational and currently has no header actions.',
|
||||
'Tenantless detail view keeps back-navigation, refresh, related links, and resumable operation actions in the header.',
|
||||
);
|
||||
}
|
||||
|
||||
@ -107,422 +103,10 @@ public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Run')
|
||||
->schema([
|
||||
TextEntry::make('type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state)),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
|
||||
TextEntry::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
|
||||
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
|
||||
TextEntry::make('initiator_name')->label('Initiator'),
|
||||
TextEntry::make('target_scope_display')
|
||||
->label('Target')
|
||||
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('target_scope_empty_state')
|
||||
->label('Target')
|
||||
->getStateUsing(static fn (): string => 'No target scope details were recorded for this run.')
|
||||
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) === null)
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('elapsed')
|
||||
->label('Elapsed')
|
||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
||||
TextEntry::make('expected_duration')
|
||||
->label('Expected')
|
||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::expectedHuman($record) ?? '—'),
|
||||
TextEntry::make('stuck_guidance')
|
||||
ViewEntry::make('enterprise_detail')
|
||||
->label('')
|
||||
->getStateUsing(fn (OperationRun $record): ?string => RunDurationInsights::stuckGuidance($record))
|
||||
->visible(fn (OperationRun $record): bool => RunDurationInsights::stuckGuidance($record) !== null),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
TextEntry::make('started_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('completed_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('run_identity_hash')->label('Identity hash')->copyable(),
|
||||
])
|
||||
->extraAttributes([
|
||||
'x-init' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
||||
'x-on:visibilitychange.window' => '$wire.set(\'opsUxIsTabHidden\', document.hidden)',
|
||||
])
|
||||
->poll(function (OperationRun $record, $livewire): ?string {
|
||||
if (($livewire->opsUxIsTabHidden ?? false) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filled($livewire->mountedActions ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RunDetailPolling::interval($record);
|
||||
})
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Counts')
|
||||
->schema([
|
||||
ViewEntry::make('summary_counts')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (OperationRun $record): array => $record->summary_counts ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => ! empty($record->summary_counts))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Related context')
|
||||
->schema([
|
||||
ViewEntry::make('related_context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.related-context')
|
||||
->state(fn (OperationRun $record): array => app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Failures')
|
||||
->schema([
|
||||
ViewEntry::make('failure_summary')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (OperationRun $record): array => $record->failure_summary ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => ! empty($record->failure_summary))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Baseline compare')
|
||||
->schema([
|
||||
TextEntry::make('baseline_compare_fidelity')
|
||||
->label('Fidelity')
|
||||
->badge()
|
||||
->getStateUsing(function (OperationRun $record): string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||
|
||||
return is_string($fidelity) && $fidelity !== '' ? $fidelity : 'meta';
|
||||
}),
|
||||
TextEntry::make('baseline_compare_coverage_status')
|
||||
->label('Coverage')
|
||||
->badge()
|
||||
->getStateUsing(function (OperationRun $record): string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$proof = data_get($context, 'baseline_compare.coverage.proof');
|
||||
$proof = is_bool($proof) ? $proof : null;
|
||||
|
||||
$uncovered = data_get($context, 'baseline_compare.coverage.uncovered_types');
|
||||
$uncovered = is_array($uncovered) ? array_values(array_filter($uncovered, 'is_string')) : [];
|
||||
|
||||
return match (true) {
|
||||
$proof === false => 'unproven',
|
||||
$uncovered !== [] => 'warnings',
|
||||
$proof === true => 'ok',
|
||||
default => 'unknown',
|
||||
};
|
||||
})
|
||||
->color(fn (?string $state): string => match ((string) $state) {
|
||||
'ok' => 'success',
|
||||
'warnings', 'unproven' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextEntry::make('baseline_compare_why_no_findings')
|
||||
->label('Why no findings')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$code = data_get($context, 'baseline_compare.reason_code');
|
||||
$code = is_string($code) ? trim($code) : null;
|
||||
$code = $code !== '' ? $code : null;
|
||||
|
||||
if ($code === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$enum = BaselineCompareReasonCode::tryFrom($code);
|
||||
$message = $enum?->message();
|
||||
|
||||
return ($message !== null ? $message.' (' : '').$code.($message !== null ? ')' : '');
|
||||
})
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$code = data_get($context, 'baseline_compare.reason_code');
|
||||
|
||||
return is_string($code) && trim($code) !== '';
|
||||
})
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('baseline_compare_uncovered_types')
|
||||
->label('Uncovered types')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
|
||||
$types = is_array($types) ? array_values(array_filter($types, 'is_string')) : [];
|
||||
$types = array_values(array_unique(array_filter(array_map('trim', $types), fn (string $type): bool => $type !== '')));
|
||||
|
||||
if ($types === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($types, SORT_STRING);
|
||||
|
||||
return implode(', ', array_slice($types, 0, 12)).(count($types) > 12 ? '…' : '');
|
||||
})
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$types = data_get($context, 'baseline_compare.coverage.uncovered_types');
|
||||
|
||||
return is_array($types) && $types !== [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('baseline_compare_inventory_sync_run_id')
|
||||
->label('Inventory sync run')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$syncRunId = data_get($context, 'baseline_compare.inventory_sync_run_id');
|
||||
|
||||
return is_numeric($syncRunId) ? '#'.(string) (int) $syncRunId : null;
|
||||
})
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
return is_numeric(data_get($context, 'baseline_compare.inventory_sync_run_id'));
|
||||
}),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare')
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Baseline compare evidence')
|
||||
->schema([
|
||||
TextEntry::make('baseline_compare_subjects_total')
|
||||
->label('Subjects total')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.subjects_total');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_compare_gap_count')
|
||||
->label('Evidence gaps')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.evidence_gaps.count');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_compare_resume_token')
|
||||
->label('Resume token')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
})
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->columnSpanFull()
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '';
|
||||
}),
|
||||
ViewEntry::make('baseline_compare_evidence_capture')
|
||||
->label('Evidence capture')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.evidence_capture');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('baseline_compare_evidence_gaps')
|
||||
->label('Evidence gaps')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_compare.evidence_gaps');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_compare')
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Baseline capture evidence')
|
||||
->schema([
|
||||
TextEntry::make('baseline_capture_subjects_total')
|
||||
->label('Subjects total')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.subjects_total');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_capture_gap_count')
|
||||
->label('Gaps')
|
||||
->getStateUsing(function (OperationRun $record): ?int {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.gaps.count');
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
})
|
||||
->placeholder('—'),
|
||||
TextEntry::make('baseline_capture_resume_token')
|
||||
->label('Resume token')
|
||||
->getStateUsing(function (OperationRun $record): ?string {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
})
|
||||
->copyable()
|
||||
->placeholder('—')
|
||||
->columnSpanFull()
|
||||
->visible(function (OperationRun $record): bool {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.resume_token');
|
||||
|
||||
return is_string($value) && $value !== '';
|
||||
}),
|
||||
ViewEntry::make('baseline_capture_evidence_capture')
|
||||
->label('Evidence capture')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.evidence_capture');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('baseline_capture_gaps')
|
||||
->label('Gaps')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$value = data_get($context, 'baseline_capture.gaps');
|
||||
|
||||
return is_array($value) ? $value : [];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => (string) $record->type === 'baseline_capture')
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Verification report')
|
||||
->schema([
|
||||
ViewEntry::make('verification_report')
|
||||
->label('')
|
||||
->view('filament.components.verification-report-viewer')
|
||||
->state(fn (OperationRun $record): ?array => VerificationReportViewer::report($record))
|
||||
->viewData(function (OperationRun $record): array {
|
||||
$report = VerificationReportViewer::report($record);
|
||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||
|
||||
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
|
||||
|
||||
$previousRunUrl = null;
|
||||
|
||||
if ($changeIndicator !== null) {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
$previousRunUrl = $tenant instanceof Tenant
|
||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
||||
}
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
||||
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
||||
->where('operation_run_id', (int) $record->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
->get()
|
||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
||||
$user = $ack->acknowledgedByUser;
|
||||
|
||||
return [
|
||||
(string) $ack->check_key => [
|
||||
'check_key' => (string) $ack->check_key,
|
||||
'ack_reason' => (string) $ack->ack_reason,
|
||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
||||
'expires_at' => $ack->expires_at?->toJSON(),
|
||||
'acknowledged_by' => $user instanceof User
|
||||
? [
|
||||
'id' => (int) $user->getKey(),
|
||||
'name' => (string) $user->name,
|
||||
]
|
||||
: null,
|
||||
],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
return [
|
||||
'run' => [
|
||||
'id' => (int) $record->getKey(),
|
||||
'type' => (string) $record->type,
|
||||
'status' => (string) $record->status,
|
||||
'outcome' => (string) $record->outcome,
|
||||
'started_at' => $record->started_at?->toJSON(),
|
||||
'completed_at' => $record->completed_at?->toJSON(),
|
||||
],
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
];
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => VerificationReportViewer::shouldRenderForRun($record))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Integrity')
|
||||
->schema([
|
||||
TextEntry::make('redaction_integrity_note')
|
||||
->label('')
|
||||
->getStateUsing(fn (OperationRun $record): ?string => RedactionIntegrity::noteForRun($record))
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (OperationRun $record): bool => RedactionIntegrity::noteForRun($record) !== null)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Context')
|
||||
->schema([
|
||||
ViewEntry::make('context')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(function (OperationRun $record): array {
|
||||
$context = $record->context ?? [];
|
||||
$context = is_array($context) ? $context : [];
|
||||
|
||||
if (array_key_exists('verification_report', $context)) {
|
||||
$context['verification_report'] = [
|
||||
'redacted' => true,
|
||||
'note' => 'Rendered in the Verification report section.',
|
||||
];
|
||||
}
|
||||
|
||||
return $context;
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->view('filament.infolists.entries.enterprise-detail.layout')
|
||||
->state(fn (OperationRun $record): array => static::enterpriseDetailPage($record)->toArray())
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
@ -676,6 +260,377 @@ public static function table(Table $table): Table
|
||||
->emptyStateIcon('heroicon-o-queue-list');
|
||||
}
|
||||
|
||||
private static function enterpriseDetailPage(OperationRun $record): \App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData
|
||||
{
|
||||
$factory = new \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
|
||||
$statusSpec = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, $record->status);
|
||||
$outcomeSpec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $record->outcome);
|
||||
$targetScope = static::targetScopeDisplay($record);
|
||||
$summaryLine = \App\Support\OpsUx\SummaryCountsNormalizer::renderSummaryLine(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
|
||||
$builder = \App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||
->header(new \App\Support\Ui\EnterpriseDetail\SummaryHeaderData(
|
||||
title: OperationCatalog::label((string) $record->type),
|
||||
subtitle: 'Run #'.$record->getKey(),
|
||||
statusBadges: [
|
||||
$factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor),
|
||||
$factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Target', $targetScope ?? 'No target scope details were recorded for this run.'),
|
||||
$factory->keyFact('Initiator', $record->initiator_name),
|
||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||
$factory->keyFact('Expected', RunDurationInsights::expectedHuman($record)),
|
||||
],
|
||||
descriptionHint: 'Run identity, outcome, scope, and related next steps stay ahead of stored payloads and diagnostic fragments.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'run_summary',
|
||||
kind: 'core_details',
|
||||
title: 'Run summary',
|
||||
items: [
|
||||
$factory->keyFact('Operation', OperationCatalog::label((string) $record->type)),
|
||||
$factory->keyFact('Initiator', $record->initiator_name),
|
||||
$factory->keyFact('Target scope', $targetScope ?? 'No target scope details were recorded for this run.'),
|
||||
$factory->keyFact('Expected duration', RunDurationInsights::expectedHuman($record)),
|
||||
],
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => app(RelatedNavigationResolver::class)
|
||||
->detailEntries(CrossResourceNavigationMatrix::SOURCE_OPERATION_RUN, $record)],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Current state',
|
||||
items: array_values(array_filter([
|
||||
$factory->keyFact('Status', $statusSpec->label, badge: $factory->statusBadge($statusSpec->label, $statusSpec->color, $statusSpec->icon, $statusSpec->iconColor)),
|
||||
$factory->keyFact('Outcome', $outcomeSpec->label, badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor)),
|
||||
$summaryLine !== null ? $factory->keyFact('Counts', $summaryLine) : null,
|
||||
RunDurationInsights::stuckGuidance($record) !== null ? $factory->keyFact('Guidance', RunDurationInsights::stuckGuidance($record)) : null,
|
||||
])),
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'timestamps',
|
||||
title: 'Timing',
|
||||
items: [
|
||||
$factory->keyFact('Created', static::formatDetailTimestamp($record->created_at)),
|
||||
$factory->keyFact('Started', static::formatDetailTimestamp($record->started_at)),
|
||||
$factory->keyFact('Completed', static::formatDetailTimestamp($record->completed_at)),
|
||||
$factory->keyFact('Elapsed', RunDurationInsights::elapsedHuman($record)),
|
||||
],
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Context',
|
||||
entries: [
|
||||
$factory->keyFact('Identity hash', $record->run_identity_hash),
|
||||
$factory->keyFact('Workspace scope', $record->workspace_id),
|
||||
$factory->keyFact('Tenant scope', $record->tenant_id),
|
||||
],
|
||||
description: 'Stored run context stays available for debugging without dominating the default reading path.',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => static::contextPayload($record)],
|
||||
),
|
||||
);
|
||||
|
||||
$counts = static::summaryCountFacts($record, $factory);
|
||||
|
||||
if ($counts !== []) {
|
||||
$builder->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'counts',
|
||||
kind: 'current_status',
|
||||
title: 'Counts',
|
||||
items: $counts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (! empty($record->failure_summary)) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'failures',
|
||||
kind: 'operational_context',
|
||||
title: 'Failures',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $record->failure_summary ?? []],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ((string) $record->type === 'baseline_compare') {
|
||||
$baselineCompareFacts = static::baselineCompareFacts($record, $factory);
|
||||
$baselineCompareEvidence = static::baselineCompareEvidencePayload($record);
|
||||
|
||||
if ($baselineCompareFacts !== []) {
|
||||
$builder->addSection(
|
||||
$factory->factsSection(
|
||||
id: 'baseline_compare',
|
||||
kind: 'operational_context',
|
||||
title: 'Baseline compare',
|
||||
items: $baselineCompareFacts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ($baselineCompareEvidence !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'baseline_compare_evidence',
|
||||
kind: 'operational_context',
|
||||
title: 'Baseline compare evidence',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $baselineCompareEvidence],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ((string) $record->type === 'baseline_capture') {
|
||||
$baselineCaptureEvidence = static::baselineCaptureEvidencePayload($record);
|
||||
|
||||
if ($baselineCaptureEvidence !== []) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'baseline_capture_evidence',
|
||||
kind: 'operational_context',
|
||||
title: 'Baseline capture evidence',
|
||||
view: 'filament.infolists.entries.snapshot-json',
|
||||
viewData: ['payload' => $baselineCaptureEvidence],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (VerificationReportViewer::shouldRenderForRun($record)) {
|
||||
$builder->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'verification_report',
|
||||
kind: 'operational_context',
|
||||
title: 'Verification report',
|
||||
view: 'filament.components.verification-report-viewer',
|
||||
viewData: static::verificationReportViewData($record),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return $builder->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function summaryCountFacts(
|
||||
OperationRun $record,
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
): array {
|
||||
$counts = \App\Support\OpsUx\SummaryCountsNormalizer::normalize(is_array($record->summary_counts) ? $record->summary_counts : []);
|
||||
|
||||
return array_map(
|
||||
static fn (string $key, int $value): array => $factory->keyFact(ucfirst(str_replace('_', ' ', $key)), $value),
|
||||
array_keys($counts),
|
||||
array_values($counts),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private static function baselineCompareFacts(
|
||||
OperationRun $record,
|
||||
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
|
||||
): array {
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
$facts = [];
|
||||
|
||||
$fidelity = data_get($context, 'baseline_compare.fidelity');
|
||||
if (is_string($fidelity) && trim($fidelity) !== '') {
|
||||
$facts[] = $factory->keyFact('Fidelity', $fidelity);
|
||||
}
|
||||
|
||||
$proof = data_get($context, 'baseline_compare.coverage.proof');
|
||||
$uncoveredTypes = data_get($context, 'baseline_compare.coverage.uncovered_types');
|
||||
$uncoveredTypes = is_array($uncoveredTypes) ? array_values(array_filter($uncoveredTypes, 'is_string')) : [];
|
||||
|
||||
$facts[] = $factory->keyFact(
|
||||
'Coverage',
|
||||
match (true) {
|
||||
$proof === false => 'Unproven',
|
||||
$uncoveredTypes !== [] => 'Warnings',
|
||||
$proof === true => 'Covered',
|
||||
default => 'Unknown',
|
||||
},
|
||||
);
|
||||
|
||||
$reasonCode = data_get($context, 'baseline_compare.reason_code');
|
||||
if (is_string($reasonCode) && trim($reasonCode) !== '') {
|
||||
$enum = BaselineCompareReasonCode::tryFrom(trim($reasonCode));
|
||||
$facts[] = $factory->keyFact(
|
||||
'Why no findings',
|
||||
$enum?->message() ?? trim($reasonCode),
|
||||
trim($reasonCode),
|
||||
);
|
||||
}
|
||||
|
||||
if ($uncoveredTypes !== []) {
|
||||
sort($uncoveredTypes, SORT_STRING);
|
||||
$facts[] = $factory->keyFact('Uncovered types', implode(', ', array_slice($uncoveredTypes, 0, 12)).(count($uncoveredTypes) > 12 ? '…' : ''));
|
||||
}
|
||||
|
||||
$inventorySyncRunId = data_get($context, 'baseline_compare.inventory_sync_run_id');
|
||||
if (is_numeric($inventorySyncRunId)) {
|
||||
$facts[] = $factory->keyFact('Inventory sync run', '#'.(int) $inventorySyncRunId);
|
||||
}
|
||||
|
||||
return $facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function baselineCompareEvidencePayload(OperationRun $record): array
|
||||
{
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
return array_filter([
|
||||
'subjects_total' => is_numeric(data_get($context, 'baseline_compare.subjects_total'))
|
||||
? (int) data_get($context, 'baseline_compare.subjects_total')
|
||||
: null,
|
||||
'evidence_gaps_count' => is_numeric(data_get($context, 'baseline_compare.evidence_gaps.count'))
|
||||
? (int) data_get($context, 'baseline_compare.evidence_gaps.count')
|
||||
: null,
|
||||
'resume_token' => data_get($context, 'baseline_compare.resume_token'),
|
||||
'evidence_capture' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
|
||||
? data_get($context, 'baseline_compare.evidence_capture')
|
||||
: null,
|
||||
'evidence_gaps' => is_array(data_get($context, 'baseline_compare.evidence_gaps'))
|
||||
? data_get($context, 'baseline_compare.evidence_gaps')
|
||||
: null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function baselineCaptureEvidencePayload(OperationRun $record): array
|
||||
{
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
return array_filter([
|
||||
'subjects_total' => is_numeric(data_get($context, 'baseline_capture.subjects_total'))
|
||||
? (int) data_get($context, 'baseline_capture.subjects_total')
|
||||
: null,
|
||||
'gaps_count' => is_numeric(data_get($context, 'baseline_capture.gaps.count'))
|
||||
? (int) data_get($context, 'baseline_capture.gaps.count')
|
||||
: null,
|
||||
'resume_token' => data_get($context, 'baseline_capture.resume_token'),
|
||||
'evidence_capture' => is_array(data_get($context, 'baseline_capture.evidence_capture'))
|
||||
? data_get($context, 'baseline_capture.evidence_capture')
|
||||
: null,
|
||||
'gaps' => is_array(data_get($context, 'baseline_capture.gaps'))
|
||||
? data_get($context, 'baseline_capture.gaps')
|
||||
: null,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function verificationReportViewData(OperationRun $record): array
|
||||
{
|
||||
$report = VerificationReportViewer::report($record);
|
||||
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
||||
$changeIndicator = VerificationReportChangeIndicator::forRun($record);
|
||||
$previousRunUrl = null;
|
||||
|
||||
if ($changeIndicator !== null) {
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
$previousRunUrl = $tenant instanceof Tenant
|
||||
? OperationRunLinks::view($changeIndicator['previous_report_id'], $tenant)
|
||||
: OperationRunLinks::tenantlessView($changeIndicator['previous_report_id']);
|
||||
}
|
||||
|
||||
$acknowledgements = VerificationCheckAcknowledgement::query()
|
||||
->where('tenant_id', (int) ($record->tenant_id ?? 0))
|
||||
->where('workspace_id', (int) ($record->workspace_id ?? 0))
|
||||
->where('operation_run_id', (int) $record->getKey())
|
||||
->with('acknowledgedByUser')
|
||||
->get()
|
||||
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
||||
$user = $ack->acknowledgedByUser;
|
||||
|
||||
return [
|
||||
(string) $ack->check_key => [
|
||||
'check_key' => (string) $ack->check_key,
|
||||
'ack_reason' => (string) $ack->ack_reason,
|
||||
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
||||
'expires_at' => $ack->expires_at?->toJSON(),
|
||||
'acknowledged_by' => $user instanceof User
|
||||
? [
|
||||
'id' => (int) $user->getKey(),
|
||||
'name' => (string) $user->name,
|
||||
]
|
||||
: null,
|
||||
],
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
return [
|
||||
'report' => $report,
|
||||
'run' => [
|
||||
'id' => (int) $record->getKey(),
|
||||
'type' => (string) $record->type,
|
||||
'status' => (string) $record->status,
|
||||
'outcome' => (string) $record->outcome,
|
||||
'started_at' => $record->started_at?->toJSON(),
|
||||
'completed_at' => $record->completed_at?->toJSON(),
|
||||
],
|
||||
'fingerprint' => $fingerprint,
|
||||
'changeIndicator' => $changeIndicator,
|
||||
'previousRunUrl' => $previousRunUrl,
|
||||
'acknowledgements' => $acknowledgements,
|
||||
'redactionNotes' => VerificationReportViewer::redactionNotes($report),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function contextPayload(OperationRun $record): array
|
||||
{
|
||||
$context = is_array($record->context) ? $record->context : [];
|
||||
|
||||
if (array_key_exists('verification_report', $context)) {
|
||||
$context['verification_report'] = [
|
||||
'redacted' => true,
|
||||
'note' => 'Rendered in the Verification report section.',
|
||||
];
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
private static function formatDetailTimestamp(mixed $value): string
|
||||
{
|
||||
if (! $value instanceof \Illuminate\Support\Carbon) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return $value->toDayDateTimeString();
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [];
|
||||
|
||||
@ -6,8 +6,15 @@
|
||||
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
@ -82,6 +89,107 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $relatedContext
|
||||
*/
|
||||
public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relatedContext = []): EnterpriseDetailPageData
|
||||
{
|
||||
$rendered = $this->present($snapshot);
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
|
||||
$stateBadge = $factory->statusBadge(
|
||||
$rendered->stateLabel,
|
||||
$rendered->overallGapCount > 0 ? 'warning' : 'success',
|
||||
);
|
||||
|
||||
$fidelitySpec = BadgeRenderer::spec(BadgeDomain::BaselineSnapshotFidelity, $rendered->overallFidelity->value);
|
||||
$fidelityBadge = $factory->statusBadge(
|
||||
$fidelitySpec->label,
|
||||
$fidelitySpec->color,
|
||||
$fidelitySpec->icon,
|
||||
$fidelitySpec->iconColor,
|
||||
);
|
||||
|
||||
$capturedItemCount = array_sum(array_map(
|
||||
static fn (array $row): int => (int) ($row['itemCount'] ?? 0),
|
||||
$rendered->summaryRows,
|
||||
));
|
||||
|
||||
return EnterpriseDetailBuilder::make('baseline_snapshot', 'workspace')
|
||||
->header(new SummaryHeaderData(
|
||||
title: $rendered->baselineProfileName ?? 'Baseline snapshot',
|
||||
subtitle: 'Snapshot #'.$rendered->snapshotId,
|
||||
statusBadges: [$stateBadge, $fidelityBadge],
|
||||
keyFacts: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Evidence mix', $rendered->fidelitySummary),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
$factory->keyFact('Captured items', $capturedItemCount),
|
||||
],
|
||||
descriptionHint: 'Capture context, coverage, and governance links stay ahead of technical payload detail.',
|
||||
))
|
||||
->addSection(
|
||||
$factory->viewSection(
|
||||
id: 'coverage_summary',
|
||||
kind: 'current_status',
|
||||
title: 'Coverage summary',
|
||||
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
|
||||
viewData: ['rows' => $rendered->summaryRows],
|
||||
emptyState: $factory->emptyState('No captured policy types are available in this snapshot.'),
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
view: 'filament.infolists.entries.related-context',
|
||||
viewData: ['entries' => $relatedContext],
|
||||
emptyState: $factory->emptyState('No related context is available for this record.'),
|
||||
),
|
||||
$factory->viewSection(
|
||||
id: 'captured_policy_types',
|
||||
kind: 'domain_detail',
|
||||
title: 'Captured policy types',
|
||||
view: 'filament.infolists.entries.baseline-snapshot-groups',
|
||||
viewData: ['groups' => array_map(
|
||||
static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
|
||||
$rendered->groups,
|
||||
)],
|
||||
emptyState: $factory->emptyState('No snapshot items were captured for this baseline snapshot.'),
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'status',
|
||||
title: 'Snapshot status',
|
||||
items: [
|
||||
$factory->keyFact('State', $rendered->stateLabel, badge: $stateBadge),
|
||||
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||
],
|
||||
),
|
||||
$factory->supportingFactsCard(
|
||||
kind: 'timestamps',
|
||||
title: 'Capture timing',
|
||||
items: [
|
||||
$factory->keyFact('Captured', $this->formatTimestamp($rendered->capturedAt)),
|
||||
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||
],
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
$factory->technicalDetail(
|
||||
title: 'Technical detail',
|
||||
entries: [
|
||||
$factory->keyFact('Identity hash', $rendered->snapshotIdentityHash),
|
||||
],
|
||||
description: 'Technical payloads are secondary on purpose. Use them for debugging capture fidelity and renderer fallbacks.',
|
||||
view: 'filament.infolists.entries.baseline-snapshot-technical-detail',
|
||||
viewData: ['technical' => $rendered->technicalDetail],
|
||||
),
|
||||
)
|
||||
->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BaselineSnapshotItem> $items
|
||||
*/
|
||||
@ -195,4 +303,17 @@ private function typeLabel(string $policyType): string
|
||||
?? InventoryPolicyTypeMeta::label($policyType)
|
||||
?? Str::headline($policyType);
|
||||
}
|
||||
|
||||
private function formatTimestamp(?string $value): string
|
||||
{
|
||||
if ($value === null || trim($value) === '') {
|
||||
return '—';
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($value)->toDayDateTimeString();
|
||||
} catch (Throwable) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,7 +33,6 @@ public static function baseline(): self
|
||||
'App\\Filament\\Pages\\TenantRequiredPermissions' => 'Permissions page retrofit deferred; capability checks already enforced by dedicated tests.',
|
||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
|
||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
|
||||
'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.',
|
||||
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
|
||||
'App\\Filament\\Resources\\RestoreRunResource' => 'Restore run resource retrofit deferred to restore track.',
|
||||
'App\\Filament\\Resources\\TenantResource\\RelationManagers\\TenantMembershipsRelationManager' => 'Tenant memberships relation manager retrofit deferred to RBAC membership track.',
|
||||
|
||||
73
app/Support/Ui/EnterpriseDetail/DetailSectionData.php
Normal file
73
app/Support/Ui/EnterpriseDetail/DetailSectionData.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final readonly class DetailSectionData
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @param array{title: string, description?: ?string, icon?: ?string}|null $emptyState
|
||||
* @param array<string, mixed> $viewData
|
||||
*/
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public string $kind,
|
||||
public string $title,
|
||||
public array $items = [],
|
||||
public ?array $emptyState = null,
|
||||
public ?PageActionData $action = null,
|
||||
public bool $visible = true,
|
||||
public ?string $description = null,
|
||||
public ?string $view = null,
|
||||
public array $viewData = [],
|
||||
public bool $collapsible = false,
|
||||
public bool $collapsed = false,
|
||||
) {}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
if (! $this->visible || trim($this->title) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->items !== []
|
||||
|| $this->view !== null
|
||||
|| $this->emptyState !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* id: string,
|
||||
* kind: string,
|
||||
* title: string,
|
||||
* items: list<array<string, mixed>>,
|
||||
* emptyState: array{title: string, description?: ?string, icon?: ?string}|null,
|
||||
* action: array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}|null,
|
||||
* visible: bool,
|
||||
* description: ?string,
|
||||
* view: ?string,
|
||||
* viewData: array<string, mixed>,
|
||||
* collapsible: bool,
|
||||
* collapsed: bool
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'kind' => $this->kind,
|
||||
'title' => $this->title,
|
||||
'items' => array_values($this->items),
|
||||
'emptyState' => $this->emptyState,
|
||||
'action' => $this->action?->isRenderable() === true ? $this->action->toArray() : null,
|
||||
'visible' => $this->visible,
|
||||
'description' => $this->description,
|
||||
'view' => $this->view,
|
||||
'viewData' => $this->viewData,
|
||||
'collapsible' => $this->collapsible,
|
||||
'collapsed' => $this->collapsed,
|
||||
];
|
||||
}
|
||||
}
|
||||
112
app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php
Normal file
112
app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php
Normal file
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
use LogicException;
|
||||
|
||||
final class EnterpriseDetailBuilder
|
||||
{
|
||||
private ?SummaryHeaderData $header = null;
|
||||
|
||||
/**
|
||||
* @var list<DetailSectionData>
|
||||
*/
|
||||
private array $mainSections = [];
|
||||
|
||||
/**
|
||||
* @var list<SupportingCardData>
|
||||
*/
|
||||
private array $supportingCards = [];
|
||||
|
||||
/**
|
||||
* @var list<TechnicalDetailData>
|
||||
*/
|
||||
private array $technicalSections = [];
|
||||
|
||||
/**
|
||||
* @var list<array{title: string, description?: ?string, icon?: ?string}>
|
||||
*/
|
||||
private array $emptyStateNotes = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $resourceType,
|
||||
private readonly string $scope,
|
||||
) {}
|
||||
|
||||
public static function make(string $resourceType, string $scope): self
|
||||
{
|
||||
return new self($resourceType, $scope);
|
||||
}
|
||||
|
||||
public function header(SummaryHeaderData $header): self
|
||||
{
|
||||
$this->header = $header;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addSection(DetailSectionData ...$sections): self
|
||||
{
|
||||
foreach ($sections as $section) {
|
||||
$this->mainSections[] = $section;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addSupportingCard(SupportingCardData ...$cards): self
|
||||
{
|
||||
foreach ($cards as $card) {
|
||||
$this->supportingCards[] = $card;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addTechnicalSection(TechnicalDetailData ...$sections): self
|
||||
{
|
||||
foreach ($sections as $section) {
|
||||
$this->technicalSections[] = $section;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{title: string, description?: ?string, icon?: ?string}> $notes
|
||||
*/
|
||||
public function emptyStateNotes(array $notes): self
|
||||
{
|
||||
$this->emptyStateNotes = $notes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function build(): EnterpriseDetailPageData
|
||||
{
|
||||
if (! $this->header instanceof SummaryHeaderData) {
|
||||
throw new LogicException('Enterprise detail pages require a summary header.');
|
||||
}
|
||||
|
||||
return new EnterpriseDetailPageData(
|
||||
resourceType: $this->resourceType,
|
||||
scope: $this->scope,
|
||||
header: $this->header,
|
||||
mainSections: array_values(array_filter(
|
||||
$this->mainSections,
|
||||
static fn (DetailSectionData $section): bool => $section->shouldRender(),
|
||||
)),
|
||||
supportingCards: array_values(array_filter(
|
||||
$this->supportingCards,
|
||||
static fn (SupportingCardData $card): bool => $card->shouldRender(),
|
||||
)),
|
||||
technicalSections: array_values(array_filter(
|
||||
$this->technicalSections,
|
||||
static fn (TechnicalDetailData $section): bool => $section->shouldRender(),
|
||||
)),
|
||||
emptyStateNotes: $this->emptyStateNotes,
|
||||
);
|
||||
}
|
||||
}
|
||||
64
app/Support/Ui/EnterpriseDetail/EnterpriseDetailPageData.php
Normal file
64
app/Support/Ui/EnterpriseDetail/EnterpriseDetailPageData.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final readonly class EnterpriseDetailPageData
|
||||
{
|
||||
/**
|
||||
* @param list<DetailSectionData> $mainSections
|
||||
* @param list<SupportingCardData> $supportingCards
|
||||
* @param list<TechnicalDetailData> $technicalSections
|
||||
* @param list<array{title: string, description?: ?string, icon?: ?string}> $emptyStateNotes
|
||||
*/
|
||||
public function __construct(
|
||||
public string $resourceType,
|
||||
public string $scope,
|
||||
public SummaryHeaderData $header,
|
||||
public array $mainSections = [],
|
||||
public array $supportingCards = [],
|
||||
public array $technicalSections = [],
|
||||
public array $emptyStateNotes = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* resourceType: string,
|
||||
* scope: string,
|
||||
* header: array{
|
||||
* title: string,
|
||||
* subtitle: ?string,
|
||||
* statusBadges: list<array{label: string, color?: string, icon?: ?string, iconColor?: ?string}>,
|
||||
* keyFacts: list<array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}>,
|
||||
* primaryActions: list<array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}>,
|
||||
* descriptionHint: ?string
|
||||
* },
|
||||
* mainSections: list<array<string, mixed>>,
|
||||
* supportingCards: list<array<string, mixed>>,
|
||||
* technicalSections: list<array<string, mixed>>,
|
||||
* emptyStateNotes: list<array{title: string, description?: ?string, icon?: ?string}>
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'resourceType' => $this->resourceType,
|
||||
'scope' => $this->scope,
|
||||
'header' => $this->header->toArray(),
|
||||
'mainSections' => array_values(array_map(
|
||||
static fn (DetailSectionData $section): array => $section->toArray(),
|
||||
$this->mainSections,
|
||||
)),
|
||||
'supportingCards' => array_values(array_map(
|
||||
static fn (SupportingCardData $card): array => $card->toArray(),
|
||||
$this->supportingCards,
|
||||
)),
|
||||
'technicalSections' => array_values(array_map(
|
||||
static fn (TechnicalDetailData $section): array => $section->toArray(),
|
||||
$this->technicalSections,
|
||||
)),
|
||||
'emptyStateNotes' => array_values($this->emptyStateNotes),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final class EnterpriseDetailSectionFactory
|
||||
{
|
||||
/**
|
||||
* @param array{label: string, color?: string, icon?: ?string, iconColor?: ?string}|null $badge
|
||||
* @return array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}
|
||||
*/
|
||||
public function keyFact(string $label, mixed $value, ?string $hint = null, ?array $badge = null): array
|
||||
{
|
||||
$displayValue = match (true) {
|
||||
is_bool($value) => $value ? 'Yes' : 'No',
|
||||
$value === null => '—',
|
||||
is_scalar($value) => trim((string) $value) !== '' ? (string) $value : '—',
|
||||
default => '—',
|
||||
};
|
||||
|
||||
return array_filter([
|
||||
'label' => $label,
|
||||
'value' => $displayValue,
|
||||
'hint' => $hint,
|
||||
'badge' => $badge,
|
||||
], static fn (mixed $item): bool => $item !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{label: string, color?: string, icon?: ?string, iconColor?: ?string}
|
||||
*/
|
||||
public function statusBadge(string $label, string $color = 'gray', ?string $icon = null, ?string $iconColor = null): array
|
||||
{
|
||||
return array_filter([
|
||||
'label' => $label,
|
||||
'color' => $color,
|
||||
'icon' => $icon,
|
||||
'iconColor' => $iconColor,
|
||||
], static fn (mixed $item): bool => $item !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, description?: ?string, icon?: ?string}
|
||||
*/
|
||||
public function emptyState(string $title, ?string $description = null, ?string $icon = null): array
|
||||
{
|
||||
return array_filter([
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'icon' => $icon,
|
||||
], static fn (mixed $item): bool => $item !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
*/
|
||||
public function factsSection(
|
||||
string $id,
|
||||
string $kind,
|
||||
string $title,
|
||||
array $items,
|
||||
?array $emptyState = null,
|
||||
?PageActionData $action = null,
|
||||
?string $description = null,
|
||||
bool $visible = true,
|
||||
bool $collapsible = false,
|
||||
bool $collapsed = false,
|
||||
): DetailSectionData {
|
||||
return new DetailSectionData(
|
||||
id: $id,
|
||||
kind: $kind,
|
||||
title: $title,
|
||||
items: $items,
|
||||
emptyState: $emptyState,
|
||||
action: $action,
|
||||
visible: $visible,
|
||||
description: $description,
|
||||
collapsible: $collapsible,
|
||||
collapsed: $collapsed,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $viewData
|
||||
*/
|
||||
public function viewSection(
|
||||
string $id,
|
||||
string $kind,
|
||||
string $title,
|
||||
string $view,
|
||||
array $viewData = [],
|
||||
?array $emptyState = null,
|
||||
?PageActionData $action = null,
|
||||
?string $description = null,
|
||||
bool $visible = true,
|
||||
bool $collapsible = false,
|
||||
bool $collapsed = false,
|
||||
): DetailSectionData {
|
||||
return new DetailSectionData(
|
||||
id: $id,
|
||||
kind: $kind,
|
||||
title: $title,
|
||||
emptyState: $emptyState,
|
||||
action: $action,
|
||||
visible: $visible,
|
||||
description: $description,
|
||||
view: $view,
|
||||
viewData: $viewData,
|
||||
collapsible: $collapsible,
|
||||
collapsed: $collapsed,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
*/
|
||||
public function supportingFactsCard(
|
||||
string $kind,
|
||||
string $title,
|
||||
array $items,
|
||||
?PageActionData $action = null,
|
||||
?string $description = null,
|
||||
bool $visible = true,
|
||||
?array $emptyState = null,
|
||||
): SupportingCardData {
|
||||
return new SupportingCardData(
|
||||
kind: $kind,
|
||||
title: $title,
|
||||
items: $items,
|
||||
visible: $visible,
|
||||
action: $action,
|
||||
description: $description,
|
||||
emptyState: $emptyState,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $viewData
|
||||
*/
|
||||
public function supportingViewCard(
|
||||
string $kind,
|
||||
string $title,
|
||||
string $view,
|
||||
array $viewData = [],
|
||||
?PageActionData $action = null,
|
||||
?string $description = null,
|
||||
bool $visible = true,
|
||||
?array $emptyState = null,
|
||||
): SupportingCardData {
|
||||
return new SupportingCardData(
|
||||
kind: $kind,
|
||||
title: $title,
|
||||
visible: $visible,
|
||||
action: $action,
|
||||
description: $description,
|
||||
view: $view,
|
||||
viewData: $viewData,
|
||||
emptyState: $emptyState,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $entries
|
||||
* @param array<string, mixed> $viewData
|
||||
*/
|
||||
public function technicalDetail(
|
||||
string $title,
|
||||
array $entries = [],
|
||||
?string $description = null,
|
||||
?string $view = null,
|
||||
array $viewData = [],
|
||||
?array $emptyState = null,
|
||||
bool $visible = true,
|
||||
bool $collapsible = true,
|
||||
bool $collapsed = true,
|
||||
): TechnicalDetailData {
|
||||
return new TechnicalDetailData(
|
||||
title: $title,
|
||||
entries: $entries,
|
||||
collapsible: $collapsible,
|
||||
collapsed: $collapsed,
|
||||
visible: $visible,
|
||||
description: $description,
|
||||
view: $view,
|
||||
viewData: $viewData,
|
||||
emptyState: $emptyState,
|
||||
);
|
||||
}
|
||||
}
|
||||
83
app/Support/Ui/EnterpriseDetail/FactPresentation.php
Normal file
83
app/Support/Ui/EnterpriseDetail/FactPresentation.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final class FactPresentation
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const SEMANTIC_EQUIVALENTS = [
|
||||
'yes' => 'boolean_enabled',
|
||||
'enabled' => 'boolean_enabled',
|
||||
'no' => 'boolean_disabled',
|
||||
'disabled' => 'boolean_disabled',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $fact
|
||||
*/
|
||||
public static function value(array $fact): ?string
|
||||
{
|
||||
$value = $fact['value'] ?? null;
|
||||
|
||||
if (! is_scalar($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$displayValue = trim((string) $value);
|
||||
|
||||
if ($displayValue === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$badgeLabel = self::badgeLabel(is_array($fact['badge'] ?? null) ? $fact['badge'] : null);
|
||||
|
||||
if ($badgeLabel !== null && self::isEquivalent($displayValue, $badgeLabel)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $displayValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $badge
|
||||
*/
|
||||
public static function badgeLabel(?array $badge): ?string
|
||||
{
|
||||
$label = $badge['label'] ?? null;
|
||||
|
||||
if (! is_scalar($label)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$displayLabel = trim((string) $label);
|
||||
|
||||
return $displayLabel !== '' ? $displayLabel : null;
|
||||
}
|
||||
|
||||
private static function normalize(string $value): string
|
||||
{
|
||||
return mb_strtolower(preg_replace('/\s+/', ' ', trim($value)) ?? trim($value));
|
||||
}
|
||||
|
||||
private static function isEquivalent(string $value, string $badgeLabel): bool
|
||||
{
|
||||
$normalizedValue = self::normalize($value);
|
||||
$normalizedBadgeLabel = self::normalize($badgeLabel);
|
||||
|
||||
if ($normalizedValue === $normalizedBadgeLabel) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::semanticToken($normalizedValue) !== null
|
||||
&& self::semanticToken($normalizedValue) === self::semanticToken($normalizedBadgeLabel);
|
||||
}
|
||||
|
||||
private static function semanticToken(string $value): ?string
|
||||
{
|
||||
return self::SEMANTIC_EQUIVALENTS[$value] ?? null;
|
||||
}
|
||||
}
|
||||
55
app/Support/Ui/EnterpriseDetail/PageActionData.php
Normal file
55
app/Support/Ui/EnterpriseDetail/PageActionData.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final readonly class PageActionData
|
||||
{
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public string $placement = 'header',
|
||||
public ?string $url = null,
|
||||
public ?string $actionName = null,
|
||||
public bool $destructive = false,
|
||||
public bool $requiresConfirmation = false,
|
||||
public bool $visible = true,
|
||||
public ?string $icon = null,
|
||||
public bool $openInNewTab = false,
|
||||
) {}
|
||||
|
||||
public function isRenderable(): bool
|
||||
{
|
||||
return $this->visible
|
||||
&& trim($this->label) !== ''
|
||||
&& (filled($this->url) || filled($this->actionName));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* label: string,
|
||||
* placement: string,
|
||||
* url: ?string,
|
||||
* actionName: ?string,
|
||||
* destructive: bool,
|
||||
* requiresConfirmation: bool,
|
||||
* visible: bool,
|
||||
* icon: ?string,
|
||||
* openInNewTab: bool
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'label' => $this->label,
|
||||
'placement' => $this->placement,
|
||||
'url' => $this->url,
|
||||
'actionName' => $this->actionName,
|
||||
'destructive' => $this->destructive,
|
||||
'requiresConfirmation' => $this->requiresConfirmation,
|
||||
'visible' => $this->visible,
|
||||
'icon' => $this->icon,
|
||||
'openInNewTab' => $this->openInNewTab,
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Support/Ui/EnterpriseDetail/SummaryHeaderData.php
Normal file
50
app/Support/Ui/EnterpriseDetail/SummaryHeaderData.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final readonly class SummaryHeaderData
|
||||
{
|
||||
/**
|
||||
* @param list<array{label: string, color?: string, icon?: ?string, iconColor?: ?string}> $statusBadges
|
||||
* @param list<array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}> $keyFacts
|
||||
* @param list<PageActionData> $primaryActions
|
||||
*/
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public ?string $subtitle = null,
|
||||
public array $statusBadges = [],
|
||||
public array $keyFacts = [],
|
||||
public array $primaryActions = [],
|
||||
public ?string $descriptionHint = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* title: string,
|
||||
* subtitle: ?string,
|
||||
* statusBadges: list<array{label: string, color?: string, icon?: ?string, iconColor?: ?string}>,
|
||||
* keyFacts: list<array{label: string, value: string, hint?: ?string, badge?: ?array{label: string, color?: string, icon?: ?string, iconColor?: ?string}}>,
|
||||
* primaryActions: list<array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}>,
|
||||
* descriptionHint: ?string
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->title,
|
||||
'subtitle' => $this->subtitle,
|
||||
'statusBadges' => array_values($this->statusBadges),
|
||||
'keyFacts' => array_values($this->keyFacts),
|
||||
'primaryActions' => array_values(array_map(
|
||||
static fn (PageActionData $action): array => $action->toArray(),
|
||||
array_values(array_filter(
|
||||
$this->primaryActions,
|
||||
static fn (PageActionData $action): bool => $action->isRenderable(),
|
||||
)),
|
||||
)),
|
||||
'descriptionHint' => $this->descriptionHint,
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Support/Ui/EnterpriseDetail/SupportingCardData.php
Normal file
64
app/Support/Ui/EnterpriseDetail/SupportingCardData.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final readonly class SupportingCardData
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $items
|
||||
* @param array{title: string, description?: ?string, icon?: ?string}|null $emptyState
|
||||
* @param array<string, mixed> $viewData
|
||||
*/
|
||||
public function __construct(
|
||||
public string $kind,
|
||||
public string $title,
|
||||
public array $items = [],
|
||||
public bool $visible = true,
|
||||
public ?PageActionData $action = null,
|
||||
public ?string $description = null,
|
||||
public ?string $view = null,
|
||||
public array $viewData = [],
|
||||
public ?array $emptyState = null,
|
||||
) {}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
if (! $this->visible || trim($this->title) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->items !== []
|
||||
|| $this->view !== null
|
||||
|| $this->emptyState !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* kind: string,
|
||||
* title: string,
|
||||
* items: list<array<string, mixed>>,
|
||||
* visible: bool,
|
||||
* action: array{label: string, placement: string, url: ?string, actionName: ?string, destructive: bool, requiresConfirmation: bool, visible: bool, icon: ?string, openInNewTab: bool}|null,
|
||||
* description: ?string,
|
||||
* view: ?string,
|
||||
* viewData: array<string, mixed>,
|
||||
* emptyState: array{title: string, description?: ?string, icon?: ?string}|null
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'kind' => $this->kind,
|
||||
'title' => $this->title,
|
||||
'items' => array_values($this->items),
|
||||
'visible' => $this->visible,
|
||||
'action' => $this->action?->isRenderable() === true ? $this->action->toArray() : null,
|
||||
'description' => $this->description,
|
||||
'view' => $this->view,
|
||||
'viewData' => $this->viewData,
|
||||
'emptyState' => $this->emptyState,
|
||||
];
|
||||
}
|
||||
}
|
||||
64
app/Support/Ui/EnterpriseDetail/TechnicalDetailData.php
Normal file
64
app/Support/Ui/EnterpriseDetail/TechnicalDetailData.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Ui\EnterpriseDetail;
|
||||
|
||||
final readonly class TechnicalDetailData
|
||||
{
|
||||
/**
|
||||
* @param list<array<string, mixed>> $entries
|
||||
* @param array{title: string, description?: ?string, icon?: ?string}|null $emptyState
|
||||
* @param array<string, mixed> $viewData
|
||||
*/
|
||||
public function __construct(
|
||||
public string $title,
|
||||
public array $entries = [],
|
||||
public bool $collapsible = true,
|
||||
public bool $collapsed = true,
|
||||
public bool $visible = true,
|
||||
public ?string $description = null,
|
||||
public ?string $view = null,
|
||||
public array $viewData = [],
|
||||
public ?array $emptyState = null,
|
||||
) {}
|
||||
|
||||
public function shouldRender(): bool
|
||||
{
|
||||
if (! $this->visible || trim($this->title) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->entries !== []
|
||||
|| $this->view !== null
|
||||
|| $this->emptyState !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* title: string,
|
||||
* entries: list<array<string, mixed>>,
|
||||
* collapsible: bool,
|
||||
* collapsed: bool,
|
||||
* visible: bool,
|
||||
* description: ?string,
|
||||
* view: ?string,
|
||||
* viewData: array<string, mixed>,
|
||||
* emptyState: array{title: string, description?: ?string, icon?: ?string}|null
|
||||
* }
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->title,
|
||||
'entries' => array_values($this->entries),
|
||||
'collapsible' => $this->collapsible,
|
||||
'collapsed' => $this->collapsed,
|
||||
'visible' => $this->visible,
|
||||
'description' => $this->description,
|
||||
'view' => $this->view,
|
||||
'viewData' => $this->viewData,
|
||||
'emptyState' => $this->emptyState,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,8 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
$groups = $getState() ?? [];
|
||||
$groups = isset($groups) ? $groups : (isset($getState) ? $getState() : []);
|
||||
$groups = is_array($groups) ? $groups : [];
|
||||
|
||||
$formatTimestamp = static function (?string $value): string {
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
$rows = $getState() ?? [];
|
||||
$rows = isset($rows) ? $rows : (isset($getState) ? $getState() : []);
|
||||
$rows = is_array($rows) ? $rows : [];
|
||||
|
||||
$formatTimestamp = static function (?string $value): string {
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
@php
|
||||
$technical = $getState() ?? [];
|
||||
$technical = isset($technical) ? $technical : (isset($getState) ? $getState() : []);
|
||||
$summaryPayload = is_array($technical['summaryPayload'] ?? null) ? $technical['summaryPayload'] : [];
|
||||
$groupPayloads = is_array($technical['groupPayloads'] ?? null) ? $technical['groupPayloads'] : [];
|
||||
@endphp
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
@php
|
||||
$state = $state ?? [];
|
||||
$state = is_array($state) ? $state : [];
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-dashed border-gray-300 bg-gray-50/60 px-4 py-5 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-950/30 dark:text-gray-300">
|
||||
<div class="font-medium text-gray-900 dark:text-white">
|
||||
{{ $state['title'] ?? 'No detail available' }}
|
||||
</div>
|
||||
|
||||
@if (filled($state['description'] ?? null))
|
||||
<div class="mt-1">
|
||||
{{ $state['description'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,106 @@
|
||||
@php
|
||||
use App\Support\Ui\EnterpriseDetail\FactPresentation;
|
||||
|
||||
$header = $header ?? [];
|
||||
$header = is_array($header) ? $header : [];
|
||||
|
||||
$statusBadges = array_values(array_filter($header['statusBadges'] ?? [], 'is_array'));
|
||||
$keyFacts = array_values(array_filter($header['keyFacts'] ?? [], 'is_array'));
|
||||
$primaryActions = array_values(array_filter($header['primaryActions'] ?? [], 'is_array'));
|
||||
@endphp
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white/90 p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="space-y-3">
|
||||
@if ($statusBadges !== [])
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($statusBadges as $badge)
|
||||
<x-filament::badge
|
||||
:color="$badge['color'] ?? 'gray'"
|
||||
:icon="$badge['icon'] ?? null"
|
||||
:icon-color="$badge['iconColor'] ?? null"
|
||||
>
|
||||
{{ $badge['label'] ?? 'State' }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
||||
{{ $header['title'] ?? 'Detail' }}
|
||||
</div>
|
||||
|
||||
@if (filled($header['subtitle'] ?? null))
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $header['subtitle'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (filled($header['descriptionHint'] ?? null))
|
||||
<div class="max-w-3xl text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $header['descriptionHint'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($primaryActions !== [])
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
@foreach ($primaryActions as $action)
|
||||
@if (filled($action['url'] ?? null))
|
||||
<a
|
||||
href="{{ $action['url'] }}"
|
||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ ($action['destructive'] ?? false) === true ? 'border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100 dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200' : 'border-gray-300 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800' }}"
|
||||
>
|
||||
@if (filled($action['icon'] ?? null))
|
||||
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
|
||||
@endif
|
||||
{{ $action['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($keyFacts !== [])
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($keyFacts as $fact)
|
||||
@php
|
||||
$displayValue = FactPresentation::value($fact);
|
||||
$badge = is_array($fact['badge'] ?? null) ? $fact['badge'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/40">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{{ $fact['label'] ?? 'Fact' }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
@if ($displayValue !== null)
|
||||
<span>{{ $displayValue }}</span>
|
||||
@endif
|
||||
|
||||
@if ($badge !== null)
|
||||
<x-filament::badge
|
||||
:color="$badge['color'] ?? 'gray'"
|
||||
:icon="$badge['icon'] ?? null"
|
||||
:icon-color="$badge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $badge['label'] ?? 'State' }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($fact['hint'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $fact['hint'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,69 @@
|
||||
@php
|
||||
$detail = isset($getState) ? $getState() : ($detail ?? null);
|
||||
$detail = is_array($detail) ? $detail : [];
|
||||
|
||||
$mainSections = array_values(array_filter($detail['mainSections'] ?? [], 'is_array'));
|
||||
$supportingCards = array_values(array_filter($detail['supportingCards'] ?? [], 'is_array'));
|
||||
$technicalSections = array_values(array_filter($detail['technicalSections'] ?? [], 'is_array'));
|
||||
$emptyStateNotes = array_values(array_filter($detail['emptyStateNotes'] ?? [], 'is_array'));
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
@include('filament.infolists.entries.enterprise-detail.header', [
|
||||
'header' => is_array($detail['header'] ?? null) ? $detail['header'] : [],
|
||||
])
|
||||
|
||||
@if ($emptyStateNotes !== [])
|
||||
<div class="space-y-3">
|
||||
@foreach ($emptyStateNotes as $state)
|
||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $state])
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<div class="{{ $supportingCards === [] ? 'xl:col-span-3' : 'xl:col-span-2' }} space-y-6">
|
||||
@foreach ($mainSections as $section)
|
||||
@php
|
||||
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
|
||||
$items = is_array($section['items'] ?? null) ? $section['items'] : [];
|
||||
$emptyState = is_array($section['emptyState'] ?? null) ? $section['emptyState'] : null;
|
||||
@endphp
|
||||
|
||||
<x-filament::section
|
||||
:heading="$section['title'] ?? 'Details'"
|
||||
:description="$section['description'] ?? null"
|
||||
:collapsible="(bool) ($section['collapsible'] ?? false)"
|
||||
:collapsed="(bool) ($section['collapsed'] ?? false)"
|
||||
>
|
||||
@if ($view !== null)
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
@elseif ($items !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', [
|
||||
'items' => $items,
|
||||
'action' => is_array($section['action'] ?? null) ? $section['action'] : null,
|
||||
])
|
||||
@elseif ($emptyState !== null)
|
||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($supportingCards !== [])
|
||||
<aside class="space-y-4">
|
||||
@foreach ($supportingCards as $card)
|
||||
@include('filament.infolists.entries.enterprise-detail.supporting-card', ['card' => $card])
|
||||
@endforeach
|
||||
</aside>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($technicalSections !== [])
|
||||
<div class="space-y-4">
|
||||
@foreach ($technicalSections as $section)
|
||||
@include('filament.infolists.entries.enterprise-detail.technical-detail', ['section' => $section])
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,62 @@
|
||||
@php
|
||||
use App\Support\Ui\EnterpriseDetail\FactPresentation;
|
||||
|
||||
$items = $items ?? [];
|
||||
$items = is_array($items) ? array_values(array_filter($items, 'is_array')) : [];
|
||||
$action = $action ?? null;
|
||||
$action = is_array($action) ? $action : null;
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
@foreach ($items as $item)
|
||||
@php
|
||||
$displayValue = FactPresentation::value($item);
|
||||
$badge = is_array($item['badge'] ?? null) ? $item['badge'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/30">
|
||||
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
||||
{{ $item['label'] ?? 'Detail' }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
@if ($displayValue !== null)
|
||||
<span>{{ $displayValue }}</span>
|
||||
@endif
|
||||
|
||||
@if ($badge !== null)
|
||||
<x-filament::badge
|
||||
:color="$badge['color'] ?? 'gray'"
|
||||
:icon="$badge['icon'] ?? null"
|
||||
:icon-color="$badge['iconColor'] ?? null"
|
||||
size="sm"
|
||||
>
|
||||
{{ $badge['label'] ?? 'State' }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($item['hint'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $item['hint'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($action !== null && filled($action['url'] ?? null))
|
||||
<div>
|
||||
<a
|
||||
href="{{ $action['url'] }}"
|
||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
||||
class="inline-flex items-center gap-2 text-sm font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
|
||||
>
|
||||
@if (filled($action['icon'] ?? null))
|
||||
<x-filament::icon :icon="$action['icon']" class="h-4 w-4" />
|
||||
@endif
|
||||
{{ $action['label'] }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@ -0,0 +1,45 @@
|
||||
@php
|
||||
$card = $card ?? [];
|
||||
$card = is_array($card) ? $card : [];
|
||||
|
||||
$view = is_string($card['view'] ?? null) && trim($card['view']) !== '' ? trim($card['view']) : null;
|
||||
$items = is_array($card['items'] ?? null) ? $card['items'] : [];
|
||||
$emptyState = is_array($card['emptyState'] ?? null) ? $card['emptyState'] : null;
|
||||
$action = is_array($card['action'] ?? null) ? $card['action'] : null;
|
||||
@endphp
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white/90 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ $card['title'] ?? 'Supporting detail' }}
|
||||
</div>
|
||||
|
||||
@if (filled($card['description'] ?? null))
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $card['description'] }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($action !== null && filled($action['url'] ?? null))
|
||||
<a
|
||||
href="{{ $action['url'] }}"
|
||||
@if (($action['openInNewTab'] ?? false) === true) target="_blank" rel="noreferrer noopener" @endif
|
||||
class="text-xs font-medium {{ ($action['destructive'] ?? false) === true ? 'text-rose-700 hover:text-rose-600 dark:text-rose-200 dark:hover:text-rose-100' : 'text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300' }}"
|
||||
>
|
||||
{{ $action['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
@if ($view !== null)
|
||||
@include($view, is_array($card['viewData'] ?? null) ? $card['viewData'] : [])
|
||||
@elseif ($items !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $items])
|
||||
@elseif ($emptyState !== null)
|
||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,33 @@
|
||||
@php
|
||||
$section = $section ?? [];
|
||||
$section = is_array($section) ? $section : [];
|
||||
|
||||
$view = is_string($section['view'] ?? null) && trim($section['view']) !== '' ? trim($section['view']) : null;
|
||||
$entries = is_array($section['entries'] ?? null) ? $section['entries'] : [];
|
||||
$emptyState = is_array($section['emptyState'] ?? null) ? $section['emptyState'] : null;
|
||||
@endphp
|
||||
|
||||
<x-filament::section
|
||||
:heading="$section['title'] ?? 'Technical detail'"
|
||||
:description="$section['description'] ?? 'Technical metadata stays available here without taking over the main reading path.'"
|
||||
:collapsible="(bool) ($section['collapsible'] ?? true)"
|
||||
:collapsed="(bool) ($section['collapsed'] ?? true)"
|
||||
>
|
||||
@if ($entries !== [])
|
||||
@include('filament.infolists.entries.enterprise-detail.section-items', ['items' => $entries])
|
||||
@endif
|
||||
|
||||
@if ($view !== null)
|
||||
@if ($entries !== [])
|
||||
<div class="mt-4">
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
</div>
|
||||
@else
|
||||
@include($view, is_array($section['viewData'] ?? null) ? $section['viewData'] : [])
|
||||
@endif
|
||||
@elseif ($emptyState !== null)
|
||||
<div @class(['mt-4' => $entries !== []])>
|
||||
@include('filament.infolists.entries.enterprise-detail.empty-state', ['state' => $emptyState])
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@ -1,5 +1,6 @@
|
||||
@php
|
||||
$entries = $getState() ?? [];
|
||||
$entries = isset($entries) ? $entries : (isset($getState) ? $getState() : []);
|
||||
$entries = is_array($entries) ? $entries : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
@ -49,15 +50,6 @@
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
|
||||
<a
|
||||
href="{{ $entry['targetUrl'] }}"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
|
||||
>
|
||||
{{ $entry['actionLabel'] }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if (filled($entry['contextBadge'] ?? null))
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $entry['contextBadge'] }}
|
||||
@ -69,6 +61,15 @@ class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underli
|
||||
Unavailable
|
||||
</x-filament::badge>
|
||||
@endunless
|
||||
|
||||
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
|
||||
<a
|
||||
href="{{ $entry['targetUrl'] }}"
|
||||
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
|
||||
>
|
||||
{{ $entry['actionLabel'] }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
@php
|
||||
$payload = $getState();
|
||||
$payload = array_key_exists('payload', get_defined_vars())
|
||||
? $payload
|
||||
: (isset($getState) ? $getState() : null);
|
||||
@endphp
|
||||
|
||||
@include('filament.partials.json-viewer', ['value' => $payload])
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
@php
|
||||
$pollInterval = $this->pollInterval();
|
||||
@endphp
|
||||
|
||||
<x-filament-panels::page>
|
||||
<div
|
||||
x-data
|
||||
x-init="$wire.set('opsUxIsTabHidden', document.hidden)"
|
||||
x-on:visibilitychange.window="$wire.set('opsUxIsTabHidden', document.hidden)"
|
||||
@if ($pollInterval !== null) wire:poll.{{ $pollInterval }} @endif
|
||||
>
|
||||
@if ($this->redactionIntegrityNote())
|
||||
<div class="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
{{ $this->redactionIntegrityNote() }}
|
||||
@ -6,4 +16,5 @@
|
||||
@endif
|
||||
|
||||
{{ $this->infolist }}
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
|
||||
35
specs/133-detail-page-template/checklists/requirements.md
Normal file
35
specs/133-detail-page-template/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Specification Quality Checklist: View Page Template Standard for Enterprise Detail Screens
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-10
|
||||
**Feature**: [spec.md](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/133-detail-page-template/spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass completed on 2026-03-10.
|
||||
- The spec stays focused on information hierarchy and operator outcomes while naming only existing product surfaces and scope boundaries.
|
||||
@ -0,0 +1,375 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Enterprise Detail Page Composition Contract
|
||||
version: 0.1.0
|
||||
description: >-
|
||||
Internal planning contract for the shared enterprise detail-page composition model.
|
||||
These schemas describe the logical read model that backs the HTML detail pages for the
|
||||
initial aligned targets.
|
||||
paths:
|
||||
/admin/baseline-snapshots/{snapshot}:
|
||||
get:
|
||||
summary: Resolve BaselineSnapshot enterprise detail page data
|
||||
operationId: getBaselineSnapshotEnterpriseDetail
|
||||
parameters:
|
||||
- name: snapshot
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Shared enterprise detail page model for a BaselineSnapshot detail page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/EnterpriseDetailPage'
|
||||
- type: object
|
||||
properties:
|
||||
resourceType:
|
||||
const: baseline_snapshot
|
||||
/admin/t/{tenant}/backup-sets/{backupSet}:
|
||||
get:
|
||||
summary: Resolve BackupSet enterprise detail page data
|
||||
operationId: getBackupSetEnterpriseDetail
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: backupSet
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Shared enterprise detail page model for a BackupSet detail page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/EnterpriseDetailPage'
|
||||
- type: object
|
||||
properties:
|
||||
resourceType:
|
||||
const: backup_set
|
||||
/admin/t/{tenant}/entra-groups/{entraGroup}:
|
||||
get:
|
||||
summary: Resolve EntraGroup enterprise detail page data
|
||||
operationId: getEntraGroupEnterpriseDetail
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: entraGroup
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Shared enterprise detail page model for an EntraGroup detail page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/EnterpriseDetailPage'
|
||||
- type: object
|
||||
properties:
|
||||
resourceType:
|
||||
const: entra_group
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Resolve OperationRun enterprise detail page data
|
||||
operationId: getOperationRunEnterpriseDetail
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Shared enterprise detail page model for an OperationRun detail page
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/EnterpriseDetailPage'
|
||||
- type: object
|
||||
properties:
|
||||
resourceType:
|
||||
const: operation_run
|
||||
components:
|
||||
schemas:
|
||||
EnterpriseDetailPage:
|
||||
type: object
|
||||
required:
|
||||
- resourceType
|
||||
- scope
|
||||
- header
|
||||
- mainSections
|
||||
- supportingCards
|
||||
- technicalSections
|
||||
properties:
|
||||
resourceType:
|
||||
type: string
|
||||
enum:
|
||||
- baseline_snapshot
|
||||
- backup_set
|
||||
- entra_group
|
||||
- operation_run
|
||||
scope:
|
||||
type: string
|
||||
enum:
|
||||
- workspace
|
||||
- tenant
|
||||
- workspace-context
|
||||
header:
|
||||
$ref: '#/components/schemas/SummaryHeader'
|
||||
mainSections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DetailSection'
|
||||
supportingCards:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SupportingCard'
|
||||
technicalSections:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TechnicalSection'
|
||||
emptyStateNotes:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EmptyState'
|
||||
SummaryHeader:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- statusBadges
|
||||
- keyFacts
|
||||
- primaryActions
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
subtitle:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
descriptionHint:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
statusBadges:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/StatusBadge'
|
||||
keyFacts:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/KeyFact'
|
||||
primaryActions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PageAction'
|
||||
DetailSection:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- title
|
||||
- visible
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- core_details
|
||||
- current_status
|
||||
- related_context
|
||||
- operational_context
|
||||
- recent_activity
|
||||
- domain_detail
|
||||
title:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/KeyFact'
|
||||
- $ref: '#/components/schemas/RelatedContextItem'
|
||||
action:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/PageAction'
|
||||
- type: 'null'
|
||||
emptyState:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/EmptyState'
|
||||
- type: 'null'
|
||||
visible:
|
||||
type: boolean
|
||||
SupportingCard:
|
||||
type: object
|
||||
required:
|
||||
- kind
|
||||
- title
|
||||
- visible
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- status
|
||||
- timestamps
|
||||
- related_context
|
||||
- quick_actions
|
||||
- health
|
||||
- summary
|
||||
title:
|
||||
type: string
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/KeyFact'
|
||||
- $ref: '#/components/schemas/RelatedContextItem'
|
||||
- $ref: '#/components/schemas/PageAction'
|
||||
visible:
|
||||
type: boolean
|
||||
TechnicalSection:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- entries
|
||||
- visible
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/KeyFact'
|
||||
collapsible:
|
||||
type: boolean
|
||||
visible:
|
||||
type: boolean
|
||||
RelatedContextItem:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- contextType
|
||||
- available
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
contextType:
|
||||
type: string
|
||||
enum:
|
||||
- parent
|
||||
- child
|
||||
- source_run
|
||||
- tenant
|
||||
- workspace
|
||||
- artifact
|
||||
- linked_record
|
||||
value:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
available:
|
||||
type: boolean
|
||||
emptyReason:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
PageAction:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- placement
|
||||
- destructive
|
||||
- requiresConfirmation
|
||||
- visible
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
placement:
|
||||
type: string
|
||||
enum:
|
||||
- header
|
||||
- supporting_card
|
||||
- section
|
||||
url:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
actionName:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
destructive:
|
||||
type: boolean
|
||||
requiresConfirmation:
|
||||
type: boolean
|
||||
visible:
|
||||
type: boolean
|
||||
StatusBadge:
|
||||
type: object
|
||||
required:
|
||||
- domain
|
||||
- value
|
||||
properties:
|
||||
domain:
|
||||
type: string
|
||||
value:
|
||||
type: string
|
||||
label:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
KeyFact:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
value:
|
||||
type:
|
||||
- string
|
||||
- integer
|
||||
- number
|
||||
- boolean
|
||||
- 'null'
|
||||
emphasis:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
EmptyState:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- message
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
actionLabel:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
actionUrl:
|
||||
type:
|
||||
- string
|
||||
- 'null'
|
||||
243
specs/133-detail-page-template/data-model.md
Normal file
243
specs/133-detail-page-template/data-model.md
Normal file
@ -0,0 +1,243 @@
|
||||
# Data Model: View Page Template Standard for Enterprise Detail Screens
|
||||
|
||||
**Feature**: 133-detail-page-template | **Date**: 2026-03-10
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no database tables and no schema migration. It introduces a shared page-composition model for enterprise detail screens and maps four existing targets onto that model.
|
||||
|
||||
The core design adds computed read models only:
|
||||
|
||||
1. a shared enterprise detail page model,
|
||||
2. a shared summary-header model,
|
||||
3. reusable main-section and supporting-card models,
|
||||
4. a structured related-context model,
|
||||
5. a secondary technical-detail model,
|
||||
6. target-specific presenter outputs that populate the shared shell.
|
||||
|
||||
## Existing Persistent Entities Used By The Feature
|
||||
|
||||
### BaselineSnapshot
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Snapshot identity |
|
||||
| `workspace_id` | int | Workspace isolation boundary |
|
||||
| `baseline_profile_id` | int | Primary governance relationship |
|
||||
| `captured_at` | timestamp | Key summary timestamp |
|
||||
| `snapshot_identity_hash` | string nullable | Technical identifier |
|
||||
|
||||
**Usage rules**:
|
||||
- Remains workspace-scoped.
|
||||
- Summary-first rendering must emphasize capture context, fidelity, and governance relevance ahead of technical metadata.
|
||||
|
||||
### BackupSet
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Record identity |
|
||||
| `tenant_id` | int | Tenant isolation boundary |
|
||||
| `name` | string | Primary display label |
|
||||
| `status` | string | Lifecycle summary signal |
|
||||
| `metadata` | array/json nullable | Technical or configuration detail |
|
||||
| `completed_at` | timestamp nullable | High-signal recovery or lifecycle timestamp |
|
||||
|
||||
**Usage rules**:
|
||||
- Remains tenant-scoped.
|
||||
- Detail rendering must elevate lifecycle state, scope, and recent operational relevance before raw metadata.
|
||||
|
||||
### EntraGroup
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Local record identity |
|
||||
| `tenant_id` | int | Tenant isolation boundary |
|
||||
| `display_name` | string | Primary label |
|
||||
| `entra_id` | string | Provider object identifier |
|
||||
| `group_types` | array/json nullable | Provider classification detail |
|
||||
| `security_enabled` | bool | Classification signal |
|
||||
| `mail_enabled` | bool | Classification signal |
|
||||
| `last_seen_at` | timestamp nullable | Operational freshness indicator |
|
||||
|
||||
**Usage rules**:
|
||||
- Remains tenant-scoped.
|
||||
- The page must show identity and classification before raw provider arrays.
|
||||
|
||||
### OperationRun
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `id` | int | Run identity |
|
||||
| `workspace_id` | int | Workspace-context monitoring boundary |
|
||||
| `tenant_id` | int nullable | Optional tenant context |
|
||||
| `type` | string | What ran |
|
||||
| `status` | string | Current run state |
|
||||
| `outcome` | string nullable | Terminal result |
|
||||
| `summary_counts` | array/json nullable | KPI-like operational counts |
|
||||
| `failure_summary` | array/json nullable | Secondary failure detail |
|
||||
| `context` | array/json nullable | Target scope and related metadata |
|
||||
| `created_at`, `started_at`, `completed_at` | timestamps | Operational timing |
|
||||
|
||||
**Usage rules**:
|
||||
- Remains workspace-context by route, with tenant entitlement checks for any tenant-bound related context.
|
||||
- The page must emphasize run identity, state, target scope, and outcome before failure payloads or context dumps.
|
||||
|
||||
## New Computed Read Models
|
||||
|
||||
### EnterpriseDetailPageData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `resource_type` | string enum | `baseline_snapshot`, `backup_set`, `entra_group`, `operation_run` |
|
||||
| `scope` | string enum | `workspace`, `tenant`, `workspace-context` |
|
||||
| `header` | SummaryHeaderData | Top-of-page identity and state |
|
||||
| `main_sections` | list<DetailSectionData> | Primary reading-path content |
|
||||
| `supporting_cards` | list<SupportingCardData> | Compact aside or supporting content |
|
||||
| `technical_sections` | list<TechnicalDetailData> | Secondary technical detail blocks |
|
||||
| `empty_state_notes` | list<SectionEmptyStateData> | Optional explicit empty or degraded-state notes |
|
||||
|
||||
**Rules**:
|
||||
- The page model must always render a valid summary header.
|
||||
- Technical detail may be omitted entirely when not relevant.
|
||||
- Empty or degraded sections must communicate intent explicitly rather than disappearing ambiguously.
|
||||
|
||||
### SummaryHeaderData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | string | Primary record label |
|
||||
| `subtitle` | string nullable | Secondary identifying context |
|
||||
| `status_badges` | list<StatusBadgeData> | High-signal current-state indicators |
|
||||
| `key_facts` | list<KeyFactData> | High-signal metadata shown before deep detail |
|
||||
| `primary_actions` | list<PageActionData> | Header actions available on page load |
|
||||
| `description_hint` | string nullable | Optional short domain hint |
|
||||
|
||||
**Rules**:
|
||||
- Header content must answer “what is this?” and “what is its current condition?” first.
|
||||
- Primary actions belong here, not buried in lower sections.
|
||||
|
||||
### DetailSectionData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `id` | string | Stable section key |
|
||||
| `kind` | string enum | `core_details`, `current_status`, `related_context`, `operational_context`, `recent_activity`, `domain_detail` |
|
||||
| `title` | string | Operator-facing section label |
|
||||
| `items` | list<mixed> | Page-specific presentation items |
|
||||
| `empty_state` | SectionEmptyStateData nullable | Empty or degraded-state copy |
|
||||
| `action` | PageActionData nullable | Optional section-specific action |
|
||||
| `visible` | bool | Section visibility |
|
||||
|
||||
**Rules**:
|
||||
- Section titles describe meaning, not implementation.
|
||||
- Section ordering must follow the shared reading-order contract.
|
||||
|
||||
### SupportingCardData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `kind` | string enum | `status`, `timestamps`, `related_context`, `quick_actions`, `health`, `summary` |
|
||||
| `title` | string | Compact card title |
|
||||
| `items` | list<mixed> | Card content |
|
||||
| `visible` | bool | Card visibility |
|
||||
|
||||
**Rules**:
|
||||
- Supporting cards must remain compact and high-value.
|
||||
- Raw JSON or deep field tables do not belong here.
|
||||
|
||||
### RelatedContextItemData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `label` | string | Object or relationship label |
|
||||
| `context_type` | string enum | `parent`, `child`, `source_run`, `tenant`, `workspace`, `artifact`, `linked_record` |
|
||||
| `value` | string | Visible contextual value |
|
||||
| `url` | string nullable | Canonical destination when authorized |
|
||||
| `available` | bool | Permission-aware availability flag |
|
||||
| `empty_reason` | string nullable | Degraded-state explanation when unavailable |
|
||||
|
||||
**Rules**:
|
||||
- Related context must not reveal inaccessible record identity.
|
||||
- Missing or unavailable links need explicit copy, not silent omission when the relationship matters.
|
||||
|
||||
### TechnicalDetailData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | string | Technical section title |
|
||||
| `entries` | list<KeyFactData> | Secondary identifiers or diagnostics |
|
||||
| `collapsible` | bool | Whether the block is collapsed by default |
|
||||
| `visible` | bool | Visibility |
|
||||
|
||||
**Rules**:
|
||||
- Technical detail must render after the core reading path.
|
||||
- Sensitive or privileged technical detail remains subject to existing authorization.
|
||||
|
||||
### PageActionData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `label` | string | Operator-facing action label |
|
||||
| `placement` | string enum | `header`, `supporting_card`, `section` |
|
||||
| `url` | string nullable | Navigation target |
|
||||
| `action_name` | string nullable | Existing action identifier when not purely navigational |
|
||||
| `destructive` | bool | Whether the action is destructive-like |
|
||||
| `requires_confirmation` | bool | Confirmation requirement |
|
||||
| `visible` | bool | Authorization-aware visibility |
|
||||
|
||||
**Rules**:
|
||||
- Similar actions should map to the same placement across aligned pages.
|
||||
- Existing destructive actions keep their current confirmation and authorization rules.
|
||||
|
||||
## Target-Specific View Models
|
||||
|
||||
### BaselineSnapshotDetailData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `capture_summary` | list<KeyFactData> | Capture date, fidelity, evidence mix, gap count |
|
||||
| `coverage_rows` | list<mixed> | Structured contents overview |
|
||||
| `governance_context` | list<RelatedContextItemData> | Baseline profile and source-run context |
|
||||
| `snapshot_technical_detail` | TechnicalDetailData | Identity hash and lower-level metadata |
|
||||
|
||||
### BackupSetDetailData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `lifecycle_summary` | list<KeyFactData> | Status, item count, completion state |
|
||||
| `scope_summary` | list<KeyFactData> | Type or retention-related signals |
|
||||
| `recent_related_operations` | list<RelatedContextItemData> | Related operational context |
|
||||
| `backup_technical_detail` | TechnicalDetailData | Raw metadata and secondary identifiers |
|
||||
|
||||
### EntraGroupDetailData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `identity_summary` | list<KeyFactData> | Name, type, freshness |
|
||||
| `classification_summary` | list<KeyFactData> | Security-enabled and mail-enabled meaning |
|
||||
| `governance_usage` | list<RelatedContextItemData> | Related references or policy usage when available |
|
||||
| `group_technical_detail` | TechnicalDetailData | Provider object IDs and raw provider arrays |
|
||||
|
||||
### OperationRunDetailData
|
||||
|
||||
| Field | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `run_summary` | list<KeyFactData> | Type, status, outcome, initiator, timing |
|
||||
| `target_scope_summary` | list<KeyFactData> | Target scope and elapsed or expected duration |
|
||||
| `result_summary` | list<KeyFactData> | Summary counts or outcome highlights |
|
||||
| `failure_context` | DetailSectionData nullable | Failure details when present |
|
||||
| `run_technical_detail` | TechnicalDetailData | Identity hash and context payload fragments |
|
||||
|
||||
## Visibility and Degraded-State Rules
|
||||
|
||||
| Rule | Result |
|
||||
|------|--------|
|
||||
| Every aligned page must render a summary header | Required |
|
||||
| Technical detail may be hidden or collapsed by default | Required |
|
||||
| Missing related context must show explicit empty or unavailable copy when the relationship is important | Required |
|
||||
| Supporting cards may be omitted when no compact high-value content exists | Required |
|
||||
| Pages may omit non-applicable section types without leaving empty shells | Required |
|
||||
|
||||
## Schema Impact
|
||||
|
||||
No database schema change is expected for this feature.
|
||||
247
specs/133-detail-page-template/plan.md
Normal file
247
specs/133-detail-page-template/plan.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Implementation Plan: View Page Template Standard for Enterprise Detail Screens
|
||||
|
||||
**Branch**: `133-detail-page-template` | **Date**: 2026-03-10 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/133-detail-page-template/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Standardize the four initial enterprise detail targets around one shared read-only detail-page composition contract: a summary-first header, predictable main-and-supporting layout, dedicated related-context treatment, and secondary technical detail. Reuse the repo’s existing Filament v5 sectioned infolists, centralized badge semantics, related-navigation helpers, and existing presenter patterns, while introducing a narrow shared enterprise-detail composition layer so BaselineSnapshot, BackupSet, EntraGroup, and the workspace-context OperationRun viewer stop drifting into page-specific scaffold layouts.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15 / Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
**Storage**: PostgreSQL via Laravel Sail; no schema change expected
|
||||
**Testing**: Pest v4 feature and unit tests on PHPUnit 12
|
||||
**Target Platform**: Laravel Sail web application with workspace-plane admin routes under `/admin` and tenant-context routes under `/admin/t/{tenant}/...`
|
||||
**Project Type**: Laravel monolith / Filament web application
|
||||
**Performance Goals**: Detail pages remain DB-only at render time, keep existing bounded queries, avoid new uncontrolled polling, and de-emphasize heavy technical payloads behind secondary sections
|
||||
**Constraints**: Preserve existing routes and resource semantics; keep authorization plane separation intact; keep non-member access as 404 and in-scope capability denial as 403; use Infolists instead of disabled edit forms; avoid a one-size-fits-all abstraction that hides domain-specific sections
|
||||
**Scale/Scope**: One shared detail-page standard, one shared composition layer, four aligned target pages, reusable Blade or Infolist building blocks, and focused regression coverage for section order, degraded states, action placement, and permission-aware related context
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: PASS — no inventory semantics change; the feature reorganizes page presentation only.
|
||||
- Read/write separation: PASS — the template itself is read-only. Existing linked mutations remain governed by their existing confirmation, audit, and test rules.
|
||||
- Graph contract path: PASS — no Microsoft Graph call path is added or changed.
|
||||
- Deterministic capabilities: PASS — authorization and visibility continue to flow through existing capability registries and enforcement helpers.
|
||||
- RBAC-UX planes and isolation: PASS — BaselineSnapshot and OperationRun remain workspace-plane detail surfaces, BackupSet and EntraGroup remain tenant-context detail surfaces, and cross-plane behavior stays deny-as-not-found.
|
||||
- Workspace isolation: PASS — workspace membership remains the entry rule for workspace-scoped target pages.
|
||||
- RBAC-UX destructive confirmation: PASS — no new destructive action is introduced; retained destructive actions on touched screens must keep `->requiresConfirmation()`.
|
||||
- RBAC-UX global search: PASS — BaselineSnapshot and OperationRun are already globally searchable-disabled; BackupSet and EntraGroup already have view pages, so any existing global-search behavior remains compliant.
|
||||
- Tenant isolation: PASS — tenant-owned detail pages remain tenant-scoped; related context cannot reveal inaccessible tenant records.
|
||||
- Run observability: PASS / N/A — no new `OperationRun` is created. OperationRun detail remains a read-only consumer of existing run state.
|
||||
- Ops-UX 3-surface feedback: PASS / N/A — the template standard does not add run lifecycle feedback. Existing run actions retain current Ops-UX behavior.
|
||||
- Ops-UX lifecycle and summary counts: PASS / N/A — no run transitions or new `summary_counts` producers are introduced.
|
||||
- Ops-UX guards and system runs: PASS / N/A — existing guard coverage remains relevant but unchanged in scope.
|
||||
- Automation: PASS / N/A — no queued or scheduled workflow change is required.
|
||||
- Data minimization: PASS — the standard only reorders existing authorized data and keeps technical detail secondary.
|
||||
- Badge semantics (BADGE-001): PASS — all elevated state badges continue to use centralized badge domains and renderers.
|
||||
- UI naming (UI-NAMING-001): PASS — section titles, empty states, and actions will use domain-first operator language and avoid implementation-first terms.
|
||||
- Filament UI Action Surface Contract: PASS — all touched pages remain sectioned detail surfaces with predictable header actions and no new unsupported action surfaces.
|
||||
- Filament UI UX-001: PASS — the design explicitly standardizes view pages around Infolists, structured sections, main-and-supporting hierarchy, badge consistency, and explicit empty states.
|
||||
- Filament v5 / Livewire v4 compliance: PASS — the feature stays inside the existing Filament v5 / Livewire v4 stack.
|
||||
- Provider registration (`bootstrap/providers.php`): PASS — no new panel or provider is added; existing panel registration remains in `bootstrap/providers.php`.
|
||||
- Global search resource rule: PASS — BaselineSnapshot global search remains disabled, BackupSet and EntraGroup already have view pages, and OperationRun global search remains disabled.
|
||||
- Asset strategy: PASS — no heavy asset bundle is needed; existing deploy-time `php artisan filament:assets` behavior remains sufficient.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/133-detail-page-template/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── enterprise-detail-pages.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── Operations/
|
||||
│ │ └── TenantlessOperationRunViewer.php # MODIFY — adopt shared detail shell
|
||||
│ └── Resources/
|
||||
│ ├── BaselineSnapshotResource.php # MODIFY — align infolist contract and action-surface notes
|
||||
│ ├── BaselineSnapshotResource/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ViewBaselineSnapshot.php # MODIFY — adopt shared detail shell
|
||||
│ ├── BackupSetResource.php # MODIFY — replace flat infolist with enterprise sections
|
||||
│ ├── BackupSetResource/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ViewBackupSet.php # MODIFY — align header actions and supporting layout
|
||||
│ ├── EntraGroupResource.php # MODIFY — add related-context and technical-detail separation
|
||||
│ ├── EntraGroupResource/
|
||||
│ │ └── Pages/
|
||||
│ │ └── ViewEntraGroup.php # MODIFY — adopt shared detail shell
|
||||
│ └── OperationRunResource.php # MODIFY — expose shared section composition for run detail
|
||||
├── Services/
|
||||
│ └── Baselines/
|
||||
│ └── SnapshotRendering/
|
||||
│ └── BaselineSnapshotPresenter.php # MODIFY — map to shared summary and technical-detail slots
|
||||
└── Support/
|
||||
├── Badges/
|
||||
│ └── BadgeRenderer.php # reference-only centralized badge semantics
|
||||
├── Navigation/
|
||||
│ └── RelatedNavigationResolver.php # reference-only related-context assembly
|
||||
└── Ui/
|
||||
├── ActionSurface/ # reference-only action contract helpers
|
||||
└── EnterpriseDetail/ # NEW shared detail-page composition classes/value objects/builders
|
||||
resources/
|
||||
└── views/
|
||||
└── filament/
|
||||
├── infolists/
|
||||
│ └── entries/
|
||||
│ ├── related-context.blade.php # reference-only existing related-context partial
|
||||
│ └── enterprise-detail/ # NEW shared summary/supporting/technical partials if needed
|
||||
└── pages/
|
||||
└── operations/ # reference-only current operation-run viewer Blade page
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Filament/
|
||||
│ │ ├── BaselineSnapshot*Test.php # MODIFY existing structure/degraded-state tests
|
||||
│ │ ├── BackupSetEnterpriseDetailPageTest.php # NEW detail hierarchy and action-placement coverage
|
||||
│ │ ├── EntraGroupEnterpriseDetailPageTest.php # NEW detail hierarchy and degraded-state coverage
|
||||
│ │ ├── OperationRunEnterpriseDetailPageTest.php # NEW layout and contextual-action coverage
|
||||
│ │ └── EnterpriseDetailTemplateRegressionTest.php # NEW cross-target reading-order regression coverage
|
||||
│ └── Guards/
|
||||
│ └── ActionSurfaceContractTest.php # reference-only guard coverage for changed pages
|
||||
└── Unit/
|
||||
└── Support/
|
||||
└── Ui/
|
||||
└── EnterpriseDetail/ # NEW page-composition and section-visibility tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep the feature inside the existing Laravel/Filament monolith. Introduce a narrow shared composition layer under `app/Support/Ui/EnterpriseDetail` and shared Blade or Infolist partials under `resources/views/filament/infolists/entries/enterprise-detail`, while refactoring the four target detail surfaces in place rather than creating a new panel, new data model, or standalone page system.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No Constitution Check violations. No justifications needed.
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| — | — | — |
|
||||
|
||||
## Phase 0 — Research (DONE)
|
||||
|
||||
Output:
|
||||
- `specs/133-detail-page-template/research.md`
|
||||
|
||||
Key findings captured:
|
||||
- The repo already has reusable building blocks for badge semantics, related-context assembly, action-surface declarations, and sectioned Infolists, but it does not yet have a shared main-and-supporting enterprise detail builder.
|
||||
- BaselineSnapshot already uses a presenter-driven, sectioned view, making it the strongest reference for the standard.
|
||||
- BackupSet and EntraGroup still expose flatter view-page structures, and OperationRun relies on a separate tenantless viewer that reuses `OperationRunResource::infolist()`.
|
||||
- The existing test suite already covers baseline snapshot rendering, operation-run Livewire behavior, related-navigation helpers, and action-surface guards, so the new standard can extend proven patterns instead of inventing a new testing approach.
|
||||
|
||||
## Phase 1 — Design & Contracts (DONE)
|
||||
|
||||
Outputs:
|
||||
- `specs/133-detail-page-template/data-model.md`
|
||||
- `specs/133-detail-page-template/contracts/enterprise-detail-pages.openapi.yaml`
|
||||
- `specs/133-detail-page-template/quickstart.md`
|
||||
|
||||
Design highlights:
|
||||
- Define a shared enterprise detail-page read model with explicit header, main sections, supporting cards, related context, technical detail, and empty-state handling.
|
||||
- Reuse existing `BadgeRenderer`, `RelatedNavigationResolver`, and page-specific presenters instead of introducing duplicate status or navigation logic.
|
||||
- Apply the shared shell first to OperationRun as the operational reference implementation, then BaselineSnapshot, BackupSet, and EntraGroup.
|
||||
- Keep technical metadata present but secondary, and keep domain-specific sections inside the shared structural shell.
|
||||
|
||||
## Phase 1 — Agent Context Update (DONE)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Step 1 — Introduce the shared enterprise detail composition layer
|
||||
|
||||
Goal: implement FR-133-01 through FR-133-05, FR-133-08, FR-133-09, FR-133-12, and FR-133-13.
|
||||
|
||||
Changes:
|
||||
- Add a shared read model and builder contract for enterprise detail pages under `app/Support/Ui/EnterpriseDetail`.
|
||||
- Add shared presentation partials or builder methods for summary header, supporting cards, related context, empty states, and technical-detail treatment.
|
||||
- Define a reusable section taxonomy and reading-order contract that target pages can compose explicitly.
|
||||
|
||||
Tests:
|
||||
- Add unit coverage for page composition, section visibility, technical-detail demotion, and empty-state fallback behavior.
|
||||
|
||||
### Step 2 — Refactor OperationRun detail as the first reference implementation
|
||||
|
||||
Goal: implement FR-133-02 through FR-133-11 and FR-133-18 for the workspace-context run detail surface.
|
||||
|
||||
Changes:
|
||||
- Refactor `TenantlessOperationRunViewer` and `OperationRunResource::infolist()` around the shared enterprise detail shell.
|
||||
- Elevate run identity, outcome, target scope, and quick actions into the summary or supporting regions.
|
||||
- Move failure and technical payload detail behind secondary sections while preserving existing contextual actions and Ops-UX rules.
|
||||
|
||||
Tests:
|
||||
- Extend or add Livewire feature tests for summary ordering, related context, degraded target-scope states, and preserved contextual actions.
|
||||
|
||||
### Step 3 — Refactor BaselineSnapshot detail onto the shared shell
|
||||
|
||||
Goal: implement FR-133-15 and reinforce FR-133-06, FR-133-07, and FR-133-11.
|
||||
|
||||
Changes:
|
||||
- Adapt `BaselineSnapshotPresenter` and `ViewBaselineSnapshot` to emit the shared page model.
|
||||
- Keep snapshot capture identity, fidelity, coverage, and governance context in the primary reading path.
|
||||
- Preserve the current technical-detail section as a collapsed or secondary region inside the shared shell.
|
||||
|
||||
Tests:
|
||||
- Reuse and extend existing BaselineSnapshot feature tests to assert summary-first structure, degraded-state handling, and related-context placement.
|
||||
|
||||
### Step 4 — Refactor BackupSet detail onto the shared shell
|
||||
|
||||
Goal: implement FR-133-16 and reinforce FR-133-06 through FR-133-10.
|
||||
|
||||
Changes:
|
||||
- Replace the current flat BackupSet infolist with structured lifecycle, scope, related-operations, and technical-detail sections.
|
||||
- Keep existing mutation actions grouped and confirmation-gated where already required.
|
||||
- Introduce a clearer supporting region for status, timestamps, retention or scope summary, and recent operational context.
|
||||
|
||||
Tests:
|
||||
- Add feature coverage for summary-first rendering, related-context visibility, action placement, and low-data empty states.
|
||||
|
||||
### Step 5 — Refactor EntraGroup detail onto the shared shell
|
||||
|
||||
Goal: implement FR-133-17 and reinforce FR-133-07, FR-133-08, and FR-133-11.
|
||||
|
||||
Changes:
|
||||
- Replace the current two-section group view with a structured identity summary, classification, governance relevance, related context, and secondary provider detail treatment.
|
||||
- Keep raw provider arrays or low-signal metadata in a clearly secondary technical section.
|
||||
|
||||
Tests:
|
||||
- Add feature coverage for identity-first rendering, empty related-context behavior, and tenant-scope authorization semantics on the detail page.
|
||||
|
||||
### Step 6 — Add cross-target regression protection and polish
|
||||
|
||||
Goal: implement FR-133-14, FR-133-19, FR-133-20, and FR-133-21.
|
||||
|
||||
Changes:
|
||||
- Add cross-target regression checks proving technical detail never renders above summary content, related context remains structured, and pages do not regress into flat schema-order field walls.
|
||||
- Review labels, action placement, and empty-state language against UI-NAMING-001, BADGE-001, and UX-001.
|
||||
|
||||
Tests:
|
||||
- Add one cross-target feature regression test and any needed guard updates for the changed action surfaces.
|
||||
- Run focused Sail-based Pest coverage for the four aligned pages plus relevant unit tests and existing guards.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance: preserved because every aligned screen remains inside the existing Filament v5 / Livewire v4 stack.
|
||||
- Provider registration location: unchanged; existing panel providers remain registered in `bootstrap/providers.php`.
|
||||
- Globally searchable resources: BaselineSnapshot is globally search disabled, BackupSet has a view page, EntraGroup has a view page, and OperationRun is globally search disabled.
|
||||
- Destructive actions: no new destructive actions are introduced by the template; any retained destructive actions on touched screens continue to require confirmation and existing authorization.
|
||||
- Asset strategy: no new heavy assets are planned; existing Tailwind and Filament view composition is sufficient, and deploy-time `php artisan filament:assets` remains unchanged.
|
||||
- Testing plan: add or update focused Pest feature tests for each target page’s summary-first structure, related-context rendering, degraded-state handling, and action placement, plus unit coverage for the shared composition layer and existing action-surface guards where needed.
|
||||
75
specs/133-detail-page-template/quickstart.md
Normal file
75
specs/133-detail-page-template/quickstart.md
Normal file
@ -0,0 +1,75 @@
|
||||
# Quickstart: View Page Template Standard for Enterprise Detail Screens
|
||||
|
||||
**Feature**: 133-detail-page-template | **Date**: 2026-03-10
|
||||
|
||||
## Scope
|
||||
|
||||
This feature standardizes four existing enterprise detail screens around a shared summary-first layout contract:
|
||||
|
||||
- BaselineSnapshot detail,
|
||||
- BackupSet detail,
|
||||
- EntraGroup detail,
|
||||
- OperationRun detail.
|
||||
|
||||
The implementation keeps existing routes and semantics intact while introducing:
|
||||
|
||||
- a shared enterprise-detail composition layer,
|
||||
- predictable summary, main, supporting, and technical regions,
|
||||
- structured related-context rendering,
|
||||
- secondary technical-detail treatment,
|
||||
- focused regression coverage against layout drift.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Add the shared enterprise-detail composition classes and any shared Blade or ViewEntry partials.
|
||||
2. Refactor OperationRun detail first as the operational reference implementation.
|
||||
3. Refactor BaselineSnapshot detail to map its existing presenter output into the shared shell.
|
||||
4. Refactor BackupSet detail to replace the current flat Infolist with lifecycle and related-context sections.
|
||||
5. Refactor EntraGroup detail to promote identity and classification above raw provider fields.
|
||||
6. Add unit tests for section visibility, related-context assembly, and technical-detail demotion in the shared composition layer.
|
||||
7. Add or update feature and Livewire tests for all four target pages.
|
||||
8. Run focused Sail-based tests.
|
||||
9. Run Pint on dirty files.
|
||||
|
||||
## Reference files
|
||||
|
||||
- [app/Filament/Resources/BaselineSnapshotResource.php](../../../app/Filament/Resources/BaselineSnapshotResource.php)
|
||||
- [app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php](../../../app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php)
|
||||
- [app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php](../../../app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php)
|
||||
- [app/Filament/Resources/BackupSetResource.php](../../../app/Filament/Resources/BackupSetResource.php)
|
||||
- [app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php](../../../app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php)
|
||||
- [app/Filament/Resources/EntraGroupResource.php](../../../app/Filament/Resources/EntraGroupResource.php)
|
||||
- [app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php](../../../app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php)
|
||||
- [app/Filament/Resources/OperationRunResource.php](../../../app/Filament/Resources/OperationRunResource.php)
|
||||
- [app/Filament/Pages/Operations/TenantlessOperationRunViewer.php](../../../app/Filament/Pages/Operations/TenantlessOperationRunViewer.php)
|
||||
- [app/Support/Navigation/RelatedNavigationResolver.php](../../../app/Support/Navigation/RelatedNavigationResolver.php)
|
||||
- [app/Support/Badges/BadgeRenderer.php](../../../app/Support/Badges/BadgeRenderer.php)
|
||||
- [resources/views/filament/infolists/entries/related-context.blade.php](../../../resources/views/filament/infolists/entries/related-context.blade.php)
|
||||
- [tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php](../../../tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php)
|
||||
- [tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php](../../../tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php)
|
||||
- [tests/Feature/Filament/BackupSetRelatedNavigationTest.php](../../../tests/Feature/Filament/BackupSetRelatedNavigationTest.php)
|
||||
- [tests/Feature/DirectoryGroups/BrowseGroupsTest.php](../../../tests/Feature/DirectoryGroups/BrowseGroupsTest.php)
|
||||
- [tests/Feature/Operations/TenantlessOperationRunViewerTest.php](../../../tests/Feature/Operations/TenantlessOperationRunViewerTest.php)
|
||||
- [tests/Feature/Guards/ActionSurfaceContractTest.php](../../../tests/Feature/Guards/ActionSurfaceContractTest.php)
|
||||
|
||||
## Suggested validation commands
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Unit/Support/Ui/EnterpriseDetail
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Expected outcome
|
||||
|
||||
- The four aligned detail pages feel like members of the same enterprise product family.
|
||||
- Each page answers identity, state, context, and next-step questions before exposing technical detail.
|
||||
- Related context appears in a structured, permission-aware location.
|
||||
- Technical metadata remains available but secondary.
|
||||
- Regression coverage protects against the return of flat scaffold-style detail pages.
|
||||
73
specs/133-detail-page-template/research.md
Normal file
73
specs/133-detail-page-template/research.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Research: View Page Template Standard for Enterprise Detail Screens
|
||||
|
||||
**Feature**: 133-detail-page-template | **Date**: 2026-03-10
|
||||
|
||||
## R1: Shared composition shape for enterprise detail pages
|
||||
|
||||
**Decision**: Introduce a narrow shared enterprise-detail composition layer that standardizes page header, section ordering, supporting-region cards, related context, and technical-detail treatment, while allowing each page to supply its own domain-specific sections.
|
||||
|
||||
**Rationale**: The target pages currently vary in how much structure they provide. BaselineSnapshot already uses a presenter plus sectioned Infolist, BackupSet and EntraGroup remain flatter, and OperationRun uses a custom viewer that reuses `OperationRunResource::infolist()`. A shared composition layer reduces layout drift without forcing every page into the same domain content.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Refactor each page independently with ad hoc sections: rejected because it would preserve page-by-page drift and make future adoption harder.
|
||||
- Build a fully generic page framework that hides all Filament details: rejected because it would be heavier than the problem requires and would make domain-specific sections harder to express.
|
||||
|
||||
## R2: Filament implementation pattern for the shared shell
|
||||
|
||||
**Decision**: Keep the standard inside Filament v5 read-only detail flows by composing sectioned Infolists and shared Blade or ViewEntry partials instead of switching pages to disabled edit forms or standalone Blade-only pages.
|
||||
|
||||
**Rationale**: Filament v5 guidance favors Infolists for view pages, and the repo already uses sectioned Infolists with custom Blade entries for richer content. This matches UX-001, preserves existing resource routes, and keeps detail pages inside the same action-surface and authorization model.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Use disabled forms for detail pages: rejected because UX-001 explicitly requires read-only Infolists or equivalent for view pages.
|
||||
- Replace target pages with fully custom Blade pages: rejected because it would bypass existing resource behavior and make action-surface consistency harder.
|
||||
|
||||
## R3: Reuse of centralized badge and related-context helpers
|
||||
|
||||
**Decision**: Continue to source status and classification display from centralized badge domains and continue to assemble linked context via `RelatedNavigationResolver` wherever applicable.
|
||||
|
||||
**Rationale**: The codebase already centralizes status-like meanings in `BadgeRenderer` and related links in `RelatedNavigationResolver`. Reusing those helpers keeps badge semantics aligned with BADGE-001 and ensures related-context entries remain permission-aware.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Create page-local badge mappings or related-link logic: rejected because it would reintroduce inconsistency and increase RBAC risk.
|
||||
- Inline all related links directly in each page class: rejected because it would duplicate navigation decisions and erode the structured related-context pattern.
|
||||
|
||||
## R4: Presenter strategy per target page
|
||||
|
||||
**Decision**: Use page-level presenters or builders to emit a shared page model, adapting existing presenter patterns where they already exist and introducing new builders where pages currently render raw fields directly.
|
||||
|
||||
**Rationale**: The spec explicitly requires a page-level view model or presenter layer. The repo already has a working example in `BaselineSnapshotPresenter`, and OperationRun already centralizes view structure in `OperationRunResource::infolist()`. Extending those patterns is lower-risk than embedding layout decisions directly in model-order field lists.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep layout logic directly in the resource `infolist()` methods for all pages: rejected because it makes section visibility, taxonomy, and reuse harder to enforce.
|
||||
- Create one giant presenter for every resource type: rejected because the target pages still need domain-specific data assembly.
|
||||
|
||||
## R5: Main-and-supporting layout handling
|
||||
|
||||
**Decision**: Implement the enterprise detail standard as a main-and-supporting-region shell backed by explicit section classification, not as a visual afterthought on top of existing field lists.
|
||||
|
||||
**Rationale**: The repo does not currently expose an obvious shared `MainAsideInfolist` or equivalent helper, so the standard needs an explicit composition contract. The supporting region must stay disciplined and compact, carrying status, timestamps, related context, and contextual next actions rather than leftover fields.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Leave the main/aside decision to each page: rejected because the feature exists specifically to stop those ad hoc layout choices.
|
||||
- Force every page into the same card count or identical visual shell: rejected because domain-specific sections still need freedom inside the shared hierarchy.
|
||||
|
||||
## R6: Testing approach for the standard
|
||||
|
||||
**Decision**: Use a combination of focused Pest feature tests for rendered page structure, Livewire tests where pages are mounted as components, unit tests for the shared composition layer, and existing guard tests for action-surface consistency.
|
||||
|
||||
**Rationale**: The repo already has strong detail-page coverage patterns. BaselineSnapshot has feature tests for structured rendering, fallback rendering, degraded states, and authorization. OperationRun has Livewire tests against `TenantlessOperationRunViewer`. The repo also has guard coverage like `ActionSurfaceContractTest` and `FilamentTableStandardsGuardTest`. Extending these patterns provides stable regression coverage without introducing new test infrastructure.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Rely only on HTTP feature tests: rejected because the shared page-composition layer also needs direct unit coverage.
|
||||
- Jump straight to browser tests: rejected because the main regression risks are hierarchy, section visibility, and authorization semantics, which are already well covered by feature and Livewire tests.
|
||||
|
||||
## R7: Contract artifact shape for planning
|
||||
|
||||
**Decision**: Capture the standard as an internal OpenAPI contract that models the read-only composition payload expected by each aligned detail route: header, sections, supporting cards, related context, technical detail, and empty-state metadata.
|
||||
|
||||
**Rationale**: The feature adds no external JSON API, but the planning workflow still requires a contract artifact. Modeling the enterprise detail page as an internal read-model contract creates a stable schema for the shared presenter output and makes it easier to test or extend consistently across the initial targets.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Skip the contract artifact because the screens are HTML pages: rejected because the planning workflow requires a concrete contract deliverable.
|
||||
- Create separate contracts for every page with completely different schemas: rejected because the point of the feature is convergence on one shared shell.
|
||||
179
specs/133-detail-page-template/spec.md
Normal file
179
specs/133-detail-page-template/spec.md
Normal file
@ -0,0 +1,179 @@
|
||||
# Feature Specification: View Page Template Standard for Enterprise Detail Screens
|
||||
|
||||
**Feature Branch**: `133-detail-page-template`
|
||||
**Created**: 2026-03-10
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 133 — View Page Template Standard for Enterprise Detail Screens"
|
||||
|
||||
## Spec Scope Fields
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- Workspace-scoped baseline snapshot detail at the existing BaselineSnapshot resource view route in the admin panel
|
||||
- Tenant-scoped backup set detail at the existing BackupSet resource view route
|
||||
- Tenant-scoped directory group detail at the existing EntraGroup resource view route
|
||||
- Workspace-context operation-run detail at the existing canonical Operations run viewer route
|
||||
- **Data Ownership**:
|
||||
- Workspace-owned or workspace-context surfaces: the shared detail-page standard itself, BaselineSnapshot detail, and OperationRun detail when surfaced through workspace-context Monitoring
|
||||
- Tenant-owned surfaces rendered through the standard: BackupSet detail and EntraGroup detail, plus tenant-bound related context surfaced from OperationRun detail
|
||||
- No new business entities, storage tables, or route semantics are introduced; this feature standardizes interpretation and presentation of existing records
|
||||
- **RBAC**:
|
||||
- Existing workspace membership and capability rules continue to govern BaselineSnapshot and workspace-context OperationRun detail
|
||||
- Existing tenant membership and capability rules continue to govern BackupSet and EntraGroup detail
|
||||
- Related links, related context, and technical detail remain permission-aware and must not disclose inaccessible records or fields
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: The shared detail template must not broaden scope. Workspace-scoped pages remain workspace-scoped even if a tenant was previously active, and tenant-scoped pages remain limited to the currently established tenant context. The template may surface related context from adjacent scopes only when that relationship is already authorized and canonical for the current viewer.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Every summary item, KPI, status badge, related-context entry, and quick action must be assembled through existing scope-aware authorization paths. Non-members remain 404 deny-as-not-found. Members lacking a capability for a target action or related destination receive 403 at that protected target, and the detail page itself must not leak inaccessible linked records through labels, counts, or hints.
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Understand the record quickly (Priority: P1)
|
||||
|
||||
As an operator opening a core enterprise record, I want the detail page to explain what the record is, what state it is in, and why it matters before I read low-level fields.
|
||||
|
||||
**Why this priority**: The feature only succeeds if detail pages stop behaving like schema dumps and become immediately interpretable operational workspaces.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening each target detail page and confirming that title, status, high-signal summary, and core context appear before technical detail or raw metadata.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operator opens a target detail page, **When** the page loads, **Then** the top of the page identifies the record, its current state, and its most important business context without requiring the operator to scan raw fields.
|
||||
2. **Given** a target page contains both business-critical and technical fields, **When** the operator follows the default reading path, **Then** technical detail appears only after the primary summary and operational context.
|
||||
3. **Given** two different target resources, **When** the operator moves between them, **Then** both pages feel like the same product family because they share the same top-level structure and reading order.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Triage and navigate from related context (Priority: P2)
|
||||
|
||||
As an operator investigating a record, I want related records, current state signals, and next actions to appear in predictable places so I can decide what to inspect next without hunting through fields.
|
||||
|
||||
**Why this priority**: Enterprise detail pages need to support interpretation and next-step navigation, not only record viewing.
|
||||
|
||||
**Independent Test**: Can be fully tested by verifying that each target page exposes a dedicated related-context region, predictable action placement, and compact state or KPI information in the aside or equivalent supporting zone.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a target record has important linked context, **When** its detail page renders, **Then** the links appear in a dedicated related-context area instead of being scattered through unrelated metadata.
|
||||
2. **Given** a target page offers page-level and contextual actions, **When** the operator scans the page, **Then** primary actions appear in the header and secondary actions appear in a consistent supporting region.
|
||||
3. **Given** a target resource has meaningful status or KPI data, **When** the page renders, **Then** that state is visually elevated and easy to compare across pages.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Stay oriented even when data is sparse or partial (Priority: P3)
|
||||
|
||||
As an operator viewing an incomplete or degraded record, I want the page to remain readable and intentional so I can tell what is missing and what I can still do.
|
||||
|
||||
**Why this priority**: These detail pages are operational surfaces; they must remain usable when linked data is missing, stale, partial, or intentionally unavailable.
|
||||
|
||||
**Independent Test**: Can be fully tested by rendering each target page with missing related data, incomplete metadata, or empty secondary sections and confirming that the page still communicates intent, safe fallbacks, and valid next steps.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a related record is unavailable or inaccessible, **When** the detail page renders, **Then** the related-context area shows a clear unavailable or empty state instead of a broken or ambiguous field.
|
||||
2. **Given** a page has no recent activity or no additional context to show, **When** the operator opens it, **Then** the page still renders a complete summary-first layout with explicit empty-state language.
|
||||
3. **Given** a target record is only partially hydrated, **When** the page renders, **Then** missing subsections do not collapse the whole page into an unstructured field wall.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A target record can have a valid identity but no recent activity, no related context, and no optional sections; the page must still render a complete enterprise layout with meaningful empty states.
|
||||
- A related record can exist logically but be inaccessible to the current viewer; the detail page must not leak its identity, count, or destination.
|
||||
- OperationRun detail can include sparse target-scope data or incomplete failure metadata; the page must still explain the run state and what is missing.
|
||||
- BackupSet or BaselineSnapshot detail can expose large technical metadata blocks; these must remain secondary and must not dominate the initial reading path.
|
||||
- EntraGroup detail can contain only low-signal provider metadata; the template must still surface group identity, classification, and governance relevance first.
|
||||
- Some target pages may not need every standard section; omitted sections must disappear cleanly rather than leaving empty shells or placeholder headings.
|
||||
|
||||
## Requirements
|
||||
|
||||
**Constitution alignment (required):** This feature introduces no new Microsoft Graph calls, no new data writes, no new queued work, and no new route semantics. It standardizes presentation and page composition for existing record detail screens. Existing operational mutations reachable from those pages continue to use their current `OperationRun`, confirmation, and audit behavior. No new `AuditLog` contract is introduced by the template itself.
|
||||
|
||||
**Constitution alignment (OPS-UX):** The shared detail-page standard does not create or transition `OperationRun` records. The OperationRun target page continues to reuse existing run data and surfaces. If the page exposes existing run-triggering actions such as resuming work, those actions remain bound to the existing Ops-UX contract and are not redefined by this feature.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature touches both the workspace/admin plane and tenant-context detail pages. Non-members remain 404 deny-as-not-found. Members lacking a capability to inspect a related object or execute a contextual action receive 403 only at that protected target, while the detail page suppresses unauthorized linked context and actions. Authorization remains server-side through existing policies, Gates, and capability registries. No raw capability strings or role-string checks may be introduced. Any destructive actions retained on affected screens must continue to require confirmation.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior changes.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Status, outcome, classification, boolean, and health badges shown in the standardized detail template must continue to use centralized badge semantics so that the same state means the same thing on every aligned page. Tests must cover any newly elevated or newly reused badge states on the target pages.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Any operator-facing labels introduced or normalized by the template must use domain-first vocabulary. Titles, section labels, actions, empty states, and supporting copy must describe the business object and operator intent, avoid implementation-first terms, and keep similar actions labeled consistently across the aligned pages.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies multiple Filament detail pages and one custom Filament page. The UI Action Matrix below applies. The Action Surface Contract remains satisfied because detail pages remain sectioned, actions are consolidated into predictable header or contextual regions, and no new destructive actions are introduced by the template itself.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** The aligned pages must comply with UX-001 by using structured sections, no naked field walls, view-style detail composition rather than disabled edit forms, centralized badge semantics, and explicit empty states. Because this feature focuses on view pages, create and edit layouts remain unchanged. Any justified variance from the default main/aside pattern must be documented per page in planning and implementation artifacts.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-133-01 Shared enterprise detail standard**: The system must define a shared enterprise detail-page standard for important record-view screens so aligned pages follow the same information hierarchy even when their domain content differs.
|
||||
- **FR-133-02 Summary-first header**: Every aligned detail page must begin with a summary header that presents the record title, current state, high-signal identifying context, and the most important page-level actions before deeper detail.
|
||||
- **FR-133-03 Stable reading order**: The standard must enforce a default reading order of identity, state, core details, related context, deeper operational detail, and technical detail.
|
||||
- **FR-133-04 Main and aside discipline**: Every aligned target page must use a consistent main-and-supporting-region layout or a documented justified variant that preserves the same information hierarchy.
|
||||
- **FR-133-05 Standard section taxonomy**: The standard must define reusable section meanings for summary, current state or KPIs, core details, related context, operational context, recent activity or history, and technical detail.
|
||||
- **FR-133-06 Status elevation**: Where a record has meaningful state, health, timing, counts, or outcomes, that information must be visually elevated rather than mixed into generic field lists.
|
||||
- **FR-133-07 Structured related context**: Important linked records and contextual relationships must appear in a dedicated related-context section or supporting card area instead of being scattered through raw metadata rows.
|
||||
- **FR-133-08 Technical detail is secondary**: Technical identifiers, payload fragments, diagnostic attributes, and storage-oriented metadata must remain available but must be visually secondary and placed after the primary operational content.
|
||||
- **FR-133-09 Consistent action placement**: The standard must define predictable locations for page-level actions, contextual next steps, and section-specific actions so similar actions appear in similar places across aligned pages.
|
||||
- **FR-133-10 Operational usefulness over schema order**: Aligned pages must optimize for operator interpretation, triage, and actionability rather than exhaustive model-order field listing.
|
||||
- **FR-133-11 Degraded-state resilience**: Missing related records, sparse data, incomplete metadata, or empty recent-activity sections must render as clear degraded states without breaking the page structure.
|
||||
- **FR-133-12 Explicit page composition**: Each aligned page must be assembled from explicit summary, context, and technical-detail sections rather than opportunistically rendering raw model attributes.
|
||||
- **FR-133-13 Optional section support**: The standard must support optional sections so a page can omit non-applicable content without leaving awkward gaps or placeholder shells.
|
||||
- **FR-133-14 Permission-aware related context**: Related-context entries, counts, and destinations must remain permission-aware and must not reveal inaccessible records.
|
||||
- **FR-133-15 BaselineSnapshot alignment**: BaselineSnapshot detail must adopt the standard while emphasizing snapshot identity, capture context, baseline profile, structured contents overview, related governance context, and secondary technical metadata.
|
||||
- **FR-133-16 BackupSet alignment**: BackupSet detail must adopt the standard while emphasizing backup-set identity, lifecycle state, scope or type, recovery-relevant status, recent related operations, and secondary technical configuration detail.
|
||||
- **FR-133-17 EntraGroup alignment**: EntraGroup detail must adopt the standard while emphasizing group identity, classification, tenant relevance, governance usage context, and secondary provider identifiers or raw provider attributes.
|
||||
- **FR-133-18 OperationRun alignment**: OperationRun detail must adopt the standard while emphasizing what ran, current or final state, target scope, outcome summary, failure or retry context, related artifacts, and secondary technical run metadata.
|
||||
- **FR-133-19 Future adoption path**: The standard must be documented clearly enough that future detail pages can adopt it without inventing new layout rules or one-off hierarchy decisions.
|
||||
- **FR-133-20 Route and domain stability**: The feature must keep existing resource routes, domain semantics, and underlying data contracts unchanged while standardizing page composition and presentation.
|
||||
- **FR-133-21 Regression protection**: Automated tests must protect against target pages reverting to flat scaffold-like field walls, losing structured related context, or promoting technical detail above business-critical content.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Redesigning list pages, table layouts, or global navigation information architecture
|
||||
- Redefining underlying resource semantics, storage models, or domain terminology
|
||||
- Rebuilding snapshot renderers, GUID resolution, or resolved-reference systems
|
||||
- Introducing a universal visual design system beyond what is necessary to standardize these detail pages
|
||||
- Implementing new audit-log behavior, new business workflows, or new operational data models
|
||||
- Expanding the standard to every resource in the product during this feature
|
||||
|
||||
### Assumptions
|
||||
|
||||
- The existing target screens remain the correct initial high-value set for proving the standard: BaselineSnapshot, BackupSet, EntraGroup, and OperationRun.
|
||||
- The product already has enough domain data on these pages to support summary, related-context, and technical-detail separation without adding new business entities.
|
||||
- Existing related-context helpers, badge semantics, and authorization helpers can be reused as the authoritative sources for linked context and status meaning.
|
||||
- Some target pages will remain more operational than others, so the shared standard must preserve domain-specific sections inside a common shell rather than forcing identical content.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing Filament view pages for BaselineSnapshot, BackupSet, and EntraGroup
|
||||
- Existing canonical OperationRun detail viewer used from Monitoring or Operations flows
|
||||
- Existing centralized badge semantics for status-like values
|
||||
- Existing related-context and canonical-navigation helpers
|
||||
- Existing workspace and tenant authorization enforcement patterns
|
||||
|
||||
## UI Action Matrix
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| BaselineSnapshot detail | Existing BaselineSnapshot resource in the admin panel | Unchanged list-header behavior | Existing clickable-row inspect affordance remains | Existing list behavior retained | Unchanged | Unchanged list empty state | `Open related record` plus any future non-destructive context action grouped consistently | N/A | No new audit event | View-page hierarchy changes only; immutable snapshot behavior remains unchanged. |
|
||||
| BackupSet detail | Existing BackupSet resource detail route | Unchanged list-header behavior | Existing resource inspect affordance remains | Existing list and bulk behavior retained outside this feature | Existing bulk behavior retained | Unchanged list empty state | `Open related record` with any existing backup-set mutation actions grouped consistently and still confirmation-gated where destructive | Existing create flow unchanged | Existing mutation audit behavior retained | The template standardizes the detail page, not list-level restore or delete workflows. |
|
||||
| EntraGroup detail | Existing EntraGroup resource detail route | Unchanged list-header behavior | Existing clickable-row inspect affordance remains | Existing list behavior retained | None | Unchanged list empty state | No mandatory header action; optional `Open related record` may appear only when a canonical related destination exists | N/A | No new audit event | Read-only directory-group detail remains non-destructive and permission-aware. |
|
||||
| OperationRun detail | Existing canonical Operations run viewer | N/A | Existing list inspect affordance remains `View run` from operations surfaces | Existing operations list behavior retained | Existing behavior retained | Unchanged list empty state | `Back to Operations`, `Refresh`, `Open`, and existing contextual run actions such as `Resume capture` remain in the header and follow the shared placement rules | N/A | Existing mutation audit behavior retained | This feature standardizes layout and action placement only; any existing run-triggering action keeps its current Ops-UX and confirmation behavior. |
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Enterprise Detail Page Standard**: The shared interpretation contract that defines how important record-view pages present identity, state, context, and secondary technical information.
|
||||
- **Summary Header**: The top-level page region that communicates the record title, current state, high-signal metadata, and primary actions.
|
||||
- **Related Context Block**: A dedicated presentation area for the most relevant linked records, parent or child relationships, workspace or tenant context, and canonical next destinations.
|
||||
- **Supporting Region**: The compact companion area that holds state cards, timestamps, contextual actions, and other high-value secondary information.
|
||||
- **Technical Detail Block**: The intentionally secondary area that contains identifiers, raw metadata, and diagnostics without dominating the default reading path.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-133-01 Target-page alignment**: All four initial target pages present a recognizable shared summary-first structure during acceptance review.
|
||||
- **SC-133-02 Interpretation speed**: In acceptance walkthroughs, an operator can identify the record type, current state, and immediate next step on each target page within 10 seconds of page load.
|
||||
- **SC-133-03 Reading-order compliance**: In automated regression coverage, 100% of aligned target pages render summary or state content before any technical-detail block.
|
||||
- **SC-133-04 Context visibility**: In automated and manual review, 100% of aligned target pages either show a dedicated related-context area or an explicit empty-state message describing the absence of related context.
|
||||
- **SC-133-05 Degraded-state safety**: In degraded-data test scenarios, all aligned target pages continue to render a complete page shell with clear missing-data messaging and no broken or ambiguous sections.
|
||||
- **SC-133-06 Drift reduction**: Future planning for an additional enterprise detail page can reference this spec without redefining section ordering, action placement, or technical-detail treatment.
|
||||
209
specs/133-detail-page-template/tasks.md
Normal file
209
specs/133-detail-page-template/tasks.md
Normal file
@ -0,0 +1,209 @@
|
||||
# Tasks: View Page Template Standard for Enterprise Detail Screens
|
||||
|
||||
**Input**: Design documents from `/specs/133-detail-page-template/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/enterprise-detail-pages.openapi.yaml
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior on existing Filament detail pages.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Create the shared implementation and test locations used by all story work.
|
||||
|
||||
- [X] T001 Create the shared enterprise-detail support namespace in app/Support/Ui/EnterpriseDetail/.gitkeep
|
||||
- [X] T002 Create the shared enterprise-detail view partial directory in resources/views/filament/infolists/entries/enterprise-detail/.gitkeep
|
||||
- [X] T003 Create the shared unit-test directory in tests/Unit/Support/Ui/EnterpriseDetail/.gitkeep
|
||||
- [X] T004 Create the feature-test entry points in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php, tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php, tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php, and tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Build the shared enterprise-detail composition layer before refactoring any target page.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T005 Create shared page-model value objects in app/Support/Ui/EnterpriseDetail/EnterpriseDetailPageData.php, app/Support/Ui/EnterpriseDetail/SummaryHeaderData.php, app/Support/Ui/EnterpriseDetail/DetailSectionData.php, app/Support/Ui/EnterpriseDetail/SupportingCardData.php, app/Support/Ui/EnterpriseDetail/TechnicalDetailData.php, and app/Support/Ui/EnterpriseDetail/PageActionData.php
|
||||
- [X] T006 Create the shared composition builders in app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php and app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php
|
||||
- [X] T007 [P] Create shared summary, supporting-card, technical-detail, and empty-state partials in resources/views/filament/infolists/entries/enterprise-detail/header.blade.php, resources/views/filament/infolists/entries/enterprise-detail/supporting-card.blade.php, resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php, and resources/views/filament/infolists/entries/enterprise-detail/empty-state.blade.php
|
||||
- [X] T008 [P] Add shared composition unit coverage in tests/Unit/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilderTest.php and tests/Unit/Support/Ui/EnterpriseDetail/SectionVisibilityTest.php
|
||||
|
||||
**Checkpoint**: Shared enterprise-detail infrastructure is ready; user story work can now begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Understand The Record Quickly (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Make all target detail pages summary-first so operators can identify the record, its current state, and core context before reading raw fields.
|
||||
|
||||
**Independent Test**: Open each target detail page and confirm that title, state, and high-signal summary content render before any technical-detail section or raw metadata block.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T009 [P] [US1] Extend summary-first snapshot coverage in tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php
|
||||
- [X] T010 [P] [US1] Add operation-run summary hierarchy assertions in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
- [X] T011 [P] [US1] Add backup-set summary hierarchy assertions in tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
|
||||
- [X] T012 [P] [US1] Add EntraGroup summary hierarchy assertions in tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T013 [US1] Refactor app/Filament/Resources/OperationRunResource.php to emit shared summary-first run sections instead of a flat primary field sequence
|
||||
- [X] T014 [US1] Refactor app/Filament/Pages/Operations/TenantlessOperationRunViewer.php to render the shared enterprise-detail shell for run detail
|
||||
- [X] T015 [US1] Refactor app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php to populate shared summary-header and primary-section data
|
||||
- [X] T016 [US1] Refactor app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php to render snapshot summary and content sections through the shared shell
|
||||
- [X] T017 [US1] Refactor app/Filament/Resources/BackupSetResource.php and app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php to replace flat field output with summary-first lifecycle sections
|
||||
- [X] T018 [US1] Refactor app/Filament/Resources/EntraGroupResource.php and app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php to promote group identity and classification ahead of raw provider data
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when all four target pages are summary-first and independently testable.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Triage And Navigate From Related Context (Priority: P2)
|
||||
|
||||
**Goal**: Give operators predictable related-context regions, supporting cards, and action placement so they can decide what to inspect next without hunting through fields.
|
||||
|
||||
**Independent Test**: Open each target detail page and verify that related context appears in a dedicated region, status and timestamps live in supporting cards, and page-level actions remain in predictable header positions.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T019 [P] [US2] Extend snapshot related-context coverage in tests/Feature/Filament/BaselineSnapshotRelatedContextTest.php
|
||||
- [X] T020 [P] [US2] Add operation-run related-context and action-placement assertions in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
- [X] T021 [P] [US2] Extend backup-set related navigation assertions in tests/Feature/Filament/BackupSetRelatedNavigationTest.php and tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
|
||||
- [X] T022 [P] [US2] Add EntraGroup related-context and navigation assertions in tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T023 [US2] Update app/Support/Ui/EnterpriseDetail/EnterpriseDetailBuilder.php and app/Support/Ui/EnterpriseDetail/PageActionData.php to support supporting-card actions, related-context slots, and header action placement
|
||||
- [X] T024 [US2] Update app/Support/Navigation/RelatedNavigationResolver.php to provide any missing permission-aware related-context entries needed by the aligned target pages
|
||||
- [X] T025 [US2] Update app/Filament/Resources/OperationRunResource.php and app/Filament/Pages/Operations/TenantlessOperationRunViewer.php to move run actions, target scope, and related artifacts into shared header and supporting regions
|
||||
- [X] T026 [US2] Update app/Filament/Resources/BaselineSnapshotResource.php and app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php to surface governance context and primary related navigation in the shared layout zones
|
||||
- [X] T027 [US2] Update app/Filament/Resources/BackupSetResource.php and app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php to surface related operations, timestamps, and grouped header actions in the shared shell
|
||||
- [X] T028 [US2] Update app/Filament/Resources/EntraGroupResource.php and app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php to surface tenant or governance context and any canonical related navigation in the shared shell
|
||||
- [X] T029 [US2] Update tests/Feature/Guards/ActionSurfaceContractTest.php for the changed view-header expectations in app/Filament/Resources/BaselineSnapshotResource.php, app/Filament/Resources/BackupSetResource.php, app/Filament/Resources/EntraGroupResource.php, and app/Filament/Resources/OperationRunResource.php
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when all four target pages expose predictable related-context and action-placement behavior.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Stay Oriented When Data Is Sparse Or Partial (Priority: P3)
|
||||
|
||||
**Goal**: Make the aligned detail pages degrade gracefully when related records, optional sections, or technical metadata are missing or incomplete.
|
||||
|
||||
**Independent Test**: Render each target page with sparse or incomplete data and confirm that it still shows a complete page shell, explicit empty or degraded states, and secondary technical-detail treatment.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T030 [P] [US3] Extend degraded snapshot coverage in tests/Feature/Filament/BaselineSnapshotDegradedStateTest.php
|
||||
- [X] T031 [P] [US3] Add backup-set empty and degraded-state assertions in tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
|
||||
- [X] T032 [P] [US3] Add EntraGroup empty and degraded related-context assertions in tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php
|
||||
- [X] T033 [P] [US3] Add sparse run metadata and failure-context fallback assertions in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T034 [US3] Update app/Support/Ui/EnterpriseDetail/TechnicalDetailData.php and resources/views/filament/infolists/entries/enterprise-detail/technical-detail.blade.php to keep technical detail secondary and collapsible by default
|
||||
- [X] T035 [US3] Update app/Support/Ui/EnterpriseDetail/EnterpriseDetailSectionFactory.php and resources/views/filament/infolists/entries/enterprise-detail/empty-state.blade.php to render explicit empty and degraded section states
|
||||
- [X] T036 [US3] Update app/Filament/Resources/OperationRunResource.php and app/Filament/Pages/Operations/TenantlessOperationRunViewer.php to handle sparse target-scope, missing failures, and technical fallback states
|
||||
- [X] T037 [US3] Update app/Filament/Resources/BackupSetResource.php and app/Filament/Resources/EntraGroupResource.php to keep low-signal metadata in technical sections and render intentional empty states
|
||||
- [X] T038 [US3] Update app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php and app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php to preserve intentional fallback copy for missing snapshot context
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when sparse-data and degraded-state scenarios remain readable across all aligned pages.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Lock in cross-target consistency, naming, verification, and formatting.
|
||||
|
||||
- [X] T039 [P] Add cross-target layout-regression coverage in tests/Feature/Filament/EnterpriseDetailTemplateRegressionTest.php
|
||||
- [X] T040 Update operator-facing section titles and action labels in app/Filament/Resources/BackupSetResource.php, app/Filament/Resources/EntraGroupResource.php, app/Filament/Resources/OperationRunResource.php, and resources/views/filament/infolists/entries/enterprise-detail/header.blade.php to align UI-NAMING-001
|
||||
- [X] T041 Run the focused validation commands documented in specs/133-detail-page-template/quickstart.md
|
||||
- [X] T042 Run formatting on touched files with vendor/bin/sail bin pint --dirty --format agent from /Users/ahmeddarrazi/Documents/projects/TenantAtlas
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user-story implementation.
|
||||
- **User Stories (Phases 3-5)**: Depend on Foundational completion.
|
||||
- **Polish (Phase 6)**: Depends on the desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Starts after Foundational and delivers the MVP by making all target pages summary-first.
|
||||
- **User Story 2 (P2)**: Starts after Foundational and builds on US1’s shared shell to add structured related context and predictable action placement.
|
||||
- **User Story 3 (P3)**: Starts after Foundational and builds on US1 and US2 to harden degraded-state handling and technical-detail demotion.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or update the story tests first and confirm they fail against the pre-change behavior.
|
||||
- Refactor shared builders before modifying page classes that consume them.
|
||||
- Update page-specific presenters before finalizing the corresponding view pages.
|
||||
- Finish story-level verification before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T007 and T008 can run in parallel after T005 and T006.
|
||||
- In US1, T009 through T012 can run in parallel, then T013 through T018 proceed in implementation order by target page.
|
||||
- In US2, T019 through T022 can run in parallel, then T024 can proceed before the page-specific refactors T025 through T028.
|
||||
- In US3, T030 through T033 can run in parallel, then T034 and T035 can proceed before the page-specific hardening tasks T036 through T038.
|
||||
- T039 and T040 can run in parallel once the story phases are complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the summary-first page tests together:
|
||||
Task: "Extend summary-first snapshot coverage in tests/Feature/Filament/BaselineSnapshotStructuredRenderingTest.php"
|
||||
Task: "Add operation-run summary hierarchy assertions in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php"
|
||||
Task: "Add backup-set summary hierarchy assertions in tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php"
|
||||
Task: "Add EntraGroup summary hierarchy assertions in tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch the related-context regression tests together:
|
||||
Task: "Extend snapshot related-context coverage in tests/Feature/Filament/BaselineSnapshotRelatedContextTest.php"
|
||||
Task: "Add operation-run related-context and action-placement assertions in tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php"
|
||||
Task: "Extend backup-set related navigation assertions in tests/Feature/Filament/BackupSetRelatedNavigationTest.php and tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php"
|
||||
Task: "Add EntraGroup related-context and navigation assertions in tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate the four target pages for summary-first structure before moving on.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish Setup and Foundational shared-layer work.
|
||||
2. Deliver User Story 1 to standardize the default reading path.
|
||||
3. Deliver User Story 2 to add structured navigation and contextual action placement.
|
||||
4. Deliver User Story 3 to harden sparse-data and degraded-state behavior.
|
||||
5. Finish with cross-target regression checks, quickstart validation, and formatting.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor builds the shared enterprise-detail layer and unit tests.
|
||||
2. After foundation is ready, contributors can split by target-page refactors inside the active story phase.
|
||||
3. One contributor finalizes cross-target regression and guard coverage while another runs the focused Sail test pack and Pint.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks touch different files and can be executed in parallel.
|
||||
- User-story labels map directly to the prioritized stories in spec.md.
|
||||
- All four target pages are intentionally included in each user-story phase so the standard converges across the full initial scope.
|
||||
- Tests are mandatory in this repo for every runtime change.
|
||||
66
tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
Normal file
66
tests/Feature/Filament/BackupSetEnterpriseDetailPageTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('renders backup sets with lifecycle summary, related context, and secondary technical detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Nightly backup',
|
||||
'item_count' => 12,
|
||||
'created_by' => 'owner@example.test',
|
||||
'metadata' => [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_assignments' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Recovery readiness')
|
||||
->assertSee('Timing')
|
||||
->assertSee('Archive')
|
||||
->assertSee('More')
|
||||
->assertSee('/admin/operations/'.$run->getKey(), false)
|
||||
->assertDontSee('Related record')
|
||||
->assertDontSee('>Completed</span>', false)
|
||||
->assertSeeInOrder(['Nightly backup', 'Lifecycle overview', 'Related context', 'Technical detail']);
|
||||
});
|
||||
|
||||
it('keeps operations context and technical empty states readable for sparse backup sets', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Sparse backup',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Operations')
|
||||
->assertSee('No backup metadata was recorded for this backup set.')
|
||||
->assertSee('Metadata keys')
|
||||
->assertDontSee('Related record')
|
||||
->assertDontSee('View run');
|
||||
});
|
||||
@ -28,7 +28,7 @@
|
||||
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee('Open operations')
|
||||
->assertSee('Operations')
|
||||
->assertSee('/admin/operations/'.$run->getKey(), false);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('index', tenant: $tenant))
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Coverage summary', 'Captured policy types', 'Technical detail'])
|
||||
->assertSee('Inventory metadata')
|
||||
->assertSee('Metadata-only evidence was captured for this item.')
|
||||
->assertSee('Only inventory metadata was available.');
|
||||
@ -108,6 +109,7 @@ public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Technical detail')
|
||||
->assertSee('Structured rendering failed for this policy type. Fallback metadata is shown instead.')
|
||||
->assertSee('Bitlocker Require')
|
||||
->assertSee('A fallback renderer is being used for this item.');
|
||||
|
||||
@ -48,8 +48,10 @@
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Related context')
|
||||
->assertSee('View baseline profile')
|
||||
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
|
||||
->assertSee('/admin/operations/'.$run->getKey(), false);
|
||||
->assertDontSee('Related record')
|
||||
->assertSee('Windows Lockdown');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl(panel: 'admin'))
|
||||
|
||||
@ -88,11 +88,11 @@
|
||||
$this->actingAs($user)
|
||||
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Snapshot status')
|
||||
->assertSee('Capture timing')
|
||||
->assertSee('Related context')
|
||||
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
|
||||
->assertSeeInOrder(['Coverage summary', 'Captured policy types', 'Technical detail'])
|
||||
->assertDontSee('No captured policy types are available in this snapshot.')
|
||||
->assertDontSee('No snapshot items were captured for this baseline snapshot.')
|
||||
->assertSeeInOrder(['Security Baseline', 'Coverage summary', 'Captured policy types', 'Technical detail'])
|
||||
->assertSee('Security Reader')
|
||||
->assertSee('Bitlocker Require')
|
||||
->assertSee('Mystery Policy')
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\BaselineSnapshot;
|
||||
use App\Models\BaselineSnapshotItem;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
it('keeps summary-first reading order across the first aligned enterprise detail pages', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$profile = BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Security Baseline',
|
||||
]);
|
||||
|
||||
$snapshot = BaselineSnapshot::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'baseline_profile_id' => (int) $profile->getKey(),
|
||||
'summary_jsonb' => [
|
||||
'fidelity_counts' => ['content' => 1, 'meta' => 0],
|
||||
'gaps' => ['count' => 0, 'by_reason' => []],
|
||||
],
|
||||
]);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'policy_type' => 'deviceCompliancePolicy',
|
||||
'meta_jsonb' => [
|
||||
'display_name' => 'Bitlocker Require',
|
||||
'evidence' => [
|
||||
'fidelity' => 'content',
|
||||
'source' => 'policy_version',
|
||||
'observed_at' => '2026-03-09T12:00:00+00:00',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Nightly backup',
|
||||
]);
|
||||
|
||||
$group = EntraGroup::factory()->for($tenant)->create([
|
||||
'display_name' => 'Group One',
|
||||
'group_types' => ['Unified'],
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'summary_counts' => ['total' => 1, 'processed' => 1, 'succeeded' => 1],
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
$this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Security Baseline', 'Captured policy types', 'Technical detail']);
|
||||
|
||||
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Nightly backup', 'Lifecycle overview', 'Technical detail']);
|
||||
|
||||
$this->get(EntraGroupResource::getUrl('view', ['record' => $group], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Group One', 'Classification overview', 'Technical detail']);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['Policy sync', 'Run summary', 'Context']);
|
||||
});
|
||||
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Models\EntraGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
function visibleGroupDetailText(TestResponse $response): string
|
||||
{
|
||||
$html = (string) $response->getContent();
|
||||
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
|
||||
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
|
||||
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
|
||||
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
|
||||
}
|
||||
|
||||
it('renders directory groups with identity and classification before technical detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$group = EntraGroup::factory()->for($tenant)->create([
|
||||
'display_name' => 'Group One',
|
||||
'entra_id' => '11111111-2222-3333-4444-555555555555',
|
||||
'group_types' => ['Unified'],
|
||||
'security_enabled' => true,
|
||||
'mail_enabled' => false,
|
||||
]);
|
||||
|
||||
$response = $this->get(EntraGroupResource::getUrl('view', ['record' => $group], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Directory identity')
|
||||
->assertSee('Freshness')
|
||||
->assertSee('Microsoft 365')
|
||||
->assertSeeInOrder(['Group One', 'Classification overview', 'Related context', 'Technical detail']);
|
||||
|
||||
$pageText = visibleGroupDetailText($response);
|
||||
|
||||
expect($pageText)->not->toContain('Security enabled Yes Enabled')
|
||||
->and($pageText)->not->toContain('Mail enabled No Disabled');
|
||||
});
|
||||
|
||||
it('renders an explicit empty related-context state for sparse groups', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$group = EntraGroup::factory()->for($tenant)->create([
|
||||
'display_name' => 'Sparse group',
|
||||
'group_types' => [],
|
||||
'security_enabled' => false,
|
||||
'mail_enabled' => false,
|
||||
'last_seen_at' => null,
|
||||
]);
|
||||
|
||||
$this->get(EntraGroupResource::getUrl('view', ['record' => $group], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('No related context is available for this record.')
|
||||
->assertSee('Classification overview')
|
||||
->assertSee('Technical detail');
|
||||
});
|
||||
144
tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Normal file
144
tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
Normal file
@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
function visiblePageText(TestResponse $response): string
|
||||
{
|
||||
$html = (string) $response->getContent();
|
||||
$html = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $html) ?? $html;
|
||||
$html = preg_replace('/<style\b[^>]*>.*?<\/style>/is', '', $html) ?? $html;
|
||||
$html = preg_replace('/\s+wire:snapshot="[^"]*"/', '', $html) ?? $html;
|
||||
$html = preg_replace('/\s+wire:effects="[^"]*"/', '', $html) ?? $html;
|
||||
|
||||
return trim((string) preg_replace('/\s+/', ' ', strip_tags($html)));
|
||||
}
|
||||
|
||||
it('renders operation runs with summary content before counts and technical context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'policy.sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'initiator_name' => 'Alice Example',
|
||||
'summary_counts' => [
|
||||
'total' => 10,
|
||||
'processed' => 10,
|
||||
'succeeded' => 10,
|
||||
],
|
||||
'context' => [
|
||||
'target_scope' => [
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
'entra_tenant_id' => '11111111-1111-1111-1111-111111111111',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Current state')
|
||||
->assertSee('Timing')
|
||||
->assertSee('Contoso');
|
||||
|
||||
$pageText = visiblePageText($response);
|
||||
|
||||
$policySyncPosition = mb_strpos($pageText, 'Policy sync');
|
||||
$runSummaryPosition = mb_strpos($pageText, 'Run summary');
|
||||
$relatedContextPosition = mb_strpos($pageText, 'Related context');
|
||||
$countsPosition = mb_strpos($pageText, 'Counts');
|
||||
$identityHashPosition = mb_strpos($pageText, 'Identity hash');
|
||||
|
||||
expect($policySyncPosition)->not->toBeFalse()
|
||||
->and($runSummaryPosition)->not->toBeFalse()
|
||||
->and($relatedContextPosition)->not->toBeFalse()
|
||||
->and($countsPosition)->not->toBeFalse()
|
||||
->and($identityHashPosition)->not->toBeFalse()
|
||||
->and($policySyncPosition)->toBeLessThan($runSummaryPosition)
|
||||
->and($runSummaryPosition)->toBeLessThan($relatedContextPosition)
|
||||
->and($relatedContextPosition)->toBeLessThan($countsPosition)
|
||||
->and($countsPosition)->toBeLessThan($identityHashPosition);
|
||||
});
|
||||
|
||||
it('keeps header navigation and related context visible for tenant-bound operation runs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$backupSet = BackupSet::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'name' => 'Nightly backup',
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'backup_set.add_policies',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'target_scope' => [
|
||||
'entra_tenant_name' => 'Contoso',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Back to Operations')
|
||||
->assertSee('Refresh')
|
||||
->assertSee('Related context')
|
||||
->assertSee('/admin/t/'.$tenant->external_id.'/backup-sets/'.$backupSet->getKey(), false);
|
||||
});
|
||||
|
||||
it('renders explicit sparse-data fallbacks for operation runs', function (): void {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'tenant_id' => null,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => [],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('No target scope details were recorded for this run.')
|
||||
->assertSee('Verification report')
|
||||
->assertSee('Verification report unavailable')
|
||||
->assertDontSee('Counts');
|
||||
});
|
||||
@ -36,3 +36,60 @@
|
||||
->and($html)->not->toContain('Resolved')
|
||||
->and($html)->toContain('Captured from drift evidence');
|
||||
});
|
||||
|
||||
it('renders related-context actions after badges for both reference and fallback entries', function (): void {
|
||||
$html = Blade::render(
|
||||
"@include('filament.infolists.entries.related-context', ['entries' => \$entries])",
|
||||
[
|
||||
'entries' => [
|
||||
[
|
||||
'label' => 'Run',
|
||||
'reference' => [
|
||||
'primaryLabel' => 'Backup set update',
|
||||
'secondaryLabel' => 'Run #189',
|
||||
'stateLabel' => 'Resolved',
|
||||
'stateColor' => 'success',
|
||||
'stateIcon' => 'heroicon-m-check-circle',
|
||||
'stateDescription' => null,
|
||||
'showStateBadge' => false,
|
||||
'isLinkable' => true,
|
||||
'linkTarget' => [
|
||||
'url' => '/admin/operations/189',
|
||||
'actionLabel' => 'Inspect run',
|
||||
'contextBadge' => 'Tenant context',
|
||||
],
|
||||
'technicalDetail' => [
|
||||
'displayId' => '189',
|
||||
'fullId' => '189',
|
||||
'sourceHint' => null,
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'Operations',
|
||||
'value' => 'Operations',
|
||||
'secondaryValue' => 'YPTW2',
|
||||
'targetUrl' => '/admin/t/demo/operations',
|
||||
'targetKind' => 'operations',
|
||||
'availability' => 'available',
|
||||
'unavailableReason' => null,
|
||||
'contextBadge' => 'Workspace context',
|
||||
'priority' => 20,
|
||||
'actionLabel' => 'Inspect operations',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$referenceBadgePosition = strpos($html, 'Tenant context');
|
||||
$referenceActionPosition = strpos($html, 'Inspect run');
|
||||
$fallbackBadgePosition = strpos($html, 'Workspace context');
|
||||
$fallbackActionPosition = strpos($html, 'Inspect operations');
|
||||
|
||||
expect($referenceBadgePosition)->not->toBeFalse()
|
||||
->and($referenceActionPosition)->not->toBeFalse()
|
||||
->and($fallbackBadgePosition)->not->toBeFalse()
|
||||
->and($fallbackActionPosition)->not->toBeFalse()
|
||||
->and($referenceBadgePosition)->toBeLessThan($referenceActionPosition)
|
||||
->and($fallbackBadgePosition)->toBeLessThan($fallbackActionPosition);
|
||||
});
|
||||
|
||||
@ -4,8 +4,11 @@
|
||||
|
||||
use App\Filament\Resources\AlertDeliveryResource;
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
|
||||
use App\Filament\Resources\OperationRunResource;
|
||||
@ -152,6 +155,9 @@
|
||||
$declarations = [
|
||||
AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(),
|
||||
BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(),
|
||||
BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(),
|
||||
BaselineSnapshotResource::class => BaselineSnapshotResource::actionSurfaceDeclaration(),
|
||||
EntraGroupResource::class => EntraGroupResource::actionSurfaceDeclaration(),
|
||||
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
|
||||
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
|
||||
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Ui\EnterpriseDetail\DetailSectionData;
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
|
||||
use App\Support\Ui\EnterpriseDetail\PageActionData;
|
||||
use App\Support\Ui\EnterpriseDetail\SummaryHeaderData;
|
||||
use App\Support\Ui\EnterpriseDetail\SupportingCardData;
|
||||
use App\Support\Ui\EnterpriseDetail\TechnicalDetailData;
|
||||
|
||||
it('requires a summary header before building a page payload', function (): void {
|
||||
expect(fn (): \App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData => EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||
->build())
|
||||
->toThrow(LogicException::class, 'Enterprise detail pages require a summary header.');
|
||||
});
|
||||
|
||||
it('filters invisible and empty sections before returning the page payload', function (): void {
|
||||
$page = EnterpriseDetailBuilder::make('operation_run', 'workspace-context')
|
||||
->header(new SummaryHeaderData(
|
||||
title: 'Policy sync',
|
||||
statusBadges: [['label' => 'Completed', 'color' => 'gray']],
|
||||
keyFacts: [['label' => 'Run', 'value' => '#44']],
|
||||
primaryActions: [
|
||||
new PageActionData(label: 'View run', url: '/admin/operations/44'),
|
||||
new PageActionData(label: 'Hidden', url: '/admin/operations/hidden', visible: false),
|
||||
],
|
||||
))
|
||||
->addSection(
|
||||
new DetailSectionData(
|
||||
id: 'counts',
|
||||
kind: 'current_status',
|
||||
title: 'Counts',
|
||||
items: [['label' => 'Processed', 'value' => '10']],
|
||||
),
|
||||
new DetailSectionData(
|
||||
id: 'hidden',
|
||||
kind: 'domain_detail',
|
||||
title: 'Hidden',
|
||||
items: [['label' => 'Ignored', 'value' => '1']],
|
||||
visible: false,
|
||||
),
|
||||
new DetailSectionData(
|
||||
id: 'empty',
|
||||
kind: 'domain_detail',
|
||||
title: 'Empty',
|
||||
),
|
||||
)
|
||||
->addSupportingCard(
|
||||
new SupportingCardData(
|
||||
kind: 'timestamps',
|
||||
title: 'Timing',
|
||||
items: [['label' => 'Completed', 'value' => '2026-03-10 09:00']],
|
||||
),
|
||||
new SupportingCardData(
|
||||
kind: 'empty',
|
||||
title: 'Ignored',
|
||||
),
|
||||
)
|
||||
->addTechnicalSection(
|
||||
new TechnicalDetailData(
|
||||
title: 'Context',
|
||||
entries: [['label' => 'Hash', 'value' => 'abc123']],
|
||||
),
|
||||
new TechnicalDetailData(
|
||||
title: 'Skipped',
|
||||
visible: false,
|
||||
entries: [['label' => 'Ignored', 'value' => '1']],
|
||||
),
|
||||
)
|
||||
->build()
|
||||
->toArray();
|
||||
|
||||
expect($page['header']['primaryActions'])->toHaveCount(1)
|
||||
->and($page['mainSections'])->toHaveCount(1)
|
||||
->and($page['mainSections'][0]['title'])->toBe('Counts')
|
||||
->and($page['supportingCards'])->toHaveCount(1)
|
||||
->and($page['supportingCards'][0]['title'])->toBe('Timing')
|
||||
->and($page['technicalSections'])->toHaveCount(1)
|
||||
->and($page['technicalSections'][0]['title'])->toBe('Context');
|
||||
});
|
||||
110
tests/Unit/Support/Ui/EnterpriseDetail/FactPresentationTest.php
Normal file
110
tests/Unit/Support/Ui/EnterpriseDetail/FactPresentationTest.php
Normal file
@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Ui\EnterpriseDetail\FactPresentation;
|
||||
|
||||
it('suppresses duplicate fact values when the badge already says the same thing', function (): void {
|
||||
expect(FactPresentation::value([
|
||||
'label' => 'Status',
|
||||
'value' => 'Completed',
|
||||
'badge' => [
|
||||
'label' => 'Completed',
|
||||
'color' => 'success',
|
||||
],
|
||||
]))->toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses boolean fact values when the badge carries the same enabled state', function (): void {
|
||||
expect(FactPresentation::value([
|
||||
'label' => 'Security enabled',
|
||||
'value' => 'Yes',
|
||||
'badge' => [
|
||||
'label' => 'Enabled',
|
||||
'color' => 'success',
|
||||
],
|
||||
]))->toBeNull();
|
||||
|
||||
expect(FactPresentation::value([
|
||||
'label' => 'Mail enabled',
|
||||
'value' => 'No',
|
||||
'badge' => [
|
||||
'label' => 'Disabled',
|
||||
'color' => 'gray',
|
||||
],
|
||||
]))->toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the fact value when it adds context beyond the badge label', function (): void {
|
||||
expect(FactPresentation::value([
|
||||
'label' => 'Completed',
|
||||
'value' => 'Completed 2 minutes ago',
|
||||
'badge' => [
|
||||
'label' => 'Completed',
|
||||
'color' => 'success',
|
||||
],
|
||||
]))->toBe('Completed 2 minutes ago');
|
||||
});
|
||||
|
||||
it('renders shared header facts without duplicating text already carried by a badge', function (): void {
|
||||
$html = view('filament.infolists.entries.enterprise-detail.header', [
|
||||
'header' => [
|
||||
'title' => 'Backup detail',
|
||||
'statusBadges' => [],
|
||||
'primaryActions' => [],
|
||||
'keyFacts' => [[
|
||||
'label' => 'Status',
|
||||
'value' => 'Completed',
|
||||
'badge' => [
|
||||
'label' => 'Completed',
|
||||
'color' => 'success',
|
||||
],
|
||||
]],
|
||||
],
|
||||
])->render();
|
||||
|
||||
expect(substr_count($html, 'Completed'))->toBe(1);
|
||||
});
|
||||
|
||||
it('renders shared section facts without duplicating text already carried by a badge', function (): void {
|
||||
$html = view('filament.infolists.entries.enterprise-detail.section-items', [
|
||||
'items' => [[
|
||||
'label' => 'Status',
|
||||
'value' => 'Completed',
|
||||
'badge' => [
|
||||
'label' => 'Completed',
|
||||
'color' => 'success',
|
||||
],
|
||||
]],
|
||||
])->render();
|
||||
|
||||
expect(substr_count($html, 'Completed'))->toBe(1);
|
||||
});
|
||||
|
||||
it('renders boolean facts without showing both yes-no text and enabled-disabled badges', function (): void {
|
||||
$html = view('filament.infolists.entries.enterprise-detail.section-items', [
|
||||
'items' => [
|
||||
[
|
||||
'label' => 'Security enabled',
|
||||
'value' => 'Yes',
|
||||
'badge' => [
|
||||
'label' => 'Enabled',
|
||||
'color' => 'success',
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => 'Mail enabled',
|
||||
'value' => 'No',
|
||||
'badge' => [
|
||||
'label' => 'Disabled',
|
||||
'color' => 'gray',
|
||||
],
|
||||
],
|
||||
],
|
||||
])->render();
|
||||
|
||||
expect($html)->not->toContain('>Yes</span>')
|
||||
->and($html)->not->toContain('>No</span>')
|
||||
->and(substr_count($html, 'Enabled'))->toBe(1)
|
||||
->and(substr_count($html, 'Disabled'))->toBe(1);
|
||||
});
|
||||
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory;
|
||||
use App\Support\Ui\EnterpriseDetail\PageActionData;
|
||||
|
||||
it('keeps empty-state-backed sections renderable when no items are present', function (): void {
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
|
||||
$section = $factory->factsSection(
|
||||
id: 'related_context',
|
||||
kind: 'related_context',
|
||||
title: 'Related context',
|
||||
items: [],
|
||||
emptyState: $factory->emptyState(
|
||||
title: 'No related context is available for this record.',
|
||||
description: 'Nothing else is linked yet.',
|
||||
),
|
||||
);
|
||||
|
||||
expect($section->shouldRender())->toBeTrue()
|
||||
->and($section->toArray()['emptyState']['title'] ?? null)->toBe('No related context is available for this record.');
|
||||
});
|
||||
|
||||
it('normalizes key facts and badges into reusable section data', function (): void {
|
||||
$factory = new EnterpriseDetailSectionFactory;
|
||||
|
||||
$section = $factory->factsSection(
|
||||
id: 'summary',
|
||||
kind: 'core_details',
|
||||
title: 'Summary',
|
||||
items: [
|
||||
$factory->keyFact(
|
||||
label: 'Status',
|
||||
value: 'Completed',
|
||||
badge: $factory->statusBadge('Completed', 'gray'),
|
||||
),
|
||||
$factory->keyFact(label: 'Target', value: null),
|
||||
],
|
||||
action: new PageActionData(
|
||||
label: 'Open related record',
|
||||
placement: 'section',
|
||||
url: '/admin/baseline-snapshots/1',
|
||||
),
|
||||
);
|
||||
|
||||
$payload = $section->toArray();
|
||||
|
||||
expect($payload['items'][0]['badge']['label'] ?? null)->toBe('Completed')
|
||||
->and($payload['items'][1]['value'])->toBe('—')
|
||||
->and($payload['action']['url'] ?? null)->toBe('/admin/baseline-snapshots/1');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user