feat: standardize Filament table UX

This commit is contained in:
Ahmed Darrazi 2026-03-08 23:47:52 +01:00
parent 3971c315d8
commit 3c3bc35d4c
66 changed files with 2550 additions and 74 deletions

View File

@ -50,6 +50,8 @@ ## Active Technologies
- PostgreSQL primary app database (123-operations-auto-refresh)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog` (124-inventory-coverage-table)
- N/A for this feature; page remains read-only and uses registry/config-derived runtime data while PostgreSQL remains unchanged (124-inventory-coverage-table)
- PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components (125-table-ux-standardization)
- PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only (125-table-ux-standardization)
- PHP 8.4.15 (feat/005-bulk-operations)
@ -69,8 +71,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions
## Recent Changes
- 125-table-ux-standardization: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components
- 124-inventory-coverage-table: Added PHP 8.4.15 + Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `CoverageCapabilitiesResolver`, `InventoryPolicyTypeMeta`, `BadgeCatalog`, and `TagBadgeCatalog`
- 124-inventory-coverage-table: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
- 124-inventory-coverage-table: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -82,8 +82,9 @@ public function table(Table $table): Table
return $table
->searchable()
->searchPlaceholder('Search by policy type or label')
->defaultSort('label')
->defaultPaginationPageOption(50)
->paginated([25, 50, 'all'])
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->records(function (
?string $sortColumn,
?string $sortDirection,

View File

@ -213,6 +213,7 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record)
? static::getUrl('view', ['record' => $record])
: null)
@ -242,7 +243,8 @@ public static function table(Table $table): Table
->label('Destination')
->placeholder('—'),
TextColumn::make('attempt_count')
->label('Attempts'),
->label('Attempts')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')

View File

@ -171,12 +171,14 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: static::getUrl('view', ['record' => $record]))
->columns([
TextColumn::make('name')
->searchable(),
->searchable()
->sortable(),
TextColumn::make('type')
->badge()
->formatStateUsing(fn (?string $state): string => self::typeLabel((string) $state)),
@ -258,7 +260,10 @@ public static function table(Table $table): Table
\Filament\Actions\CreateAction::make()
->label('Create target')
->disabled(fn (): bool => ! static::canCreate()),
]);
])
->emptyStateHeading('No alert destinations')
->emptyStateDescription('Create a destination so alert rules have somewhere to deliver notifications.')
->emptyStateIcon('heroicon-o-paper-airplane');
}
public static function getPages(): array

View File

@ -222,12 +222,14 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->recordUrl(fn (AlertRule $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: null)
->columns([
TextColumn::make('name')
->searchable(),
->searchable()
->sortable(),
TextColumn::make('event_type')
->label('Event')
->badge()
@ -311,7 +313,10 @@ public static function table(Table $table): Table
])
->bulkActions([
BulkActionGroup::make([])->label('More'),
]);
])
->emptyStateHeading('No alert rules')
->emptyStateDescription('Create a rule to route notifications when monitored events fire.')
->emptyStateIcon('heroicon-o-bell');
}
public static function getPages(): array

View File

@ -20,6 +20,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OpsUx\OperationUxPresenter;
@ -268,6 +269,10 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('next_run_at', 'asc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (BackupSchedule $record): ?string => static::canEdit($record)
? static::getUrl('edit', ['record' => $record])
: null)
@ -283,6 +288,7 @@ public static function table(Table $table): Table
TextColumn::make('name')
->searchable()
->sortable()
->label('Schedule'),
TextColumn::make('frequency')
@ -296,7 +302,8 @@ public static function table(Table $table): Table
->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null),
TextColumn::make('timezone')
->label('Timezone'),
->label('Timezone')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_types')
->label('Policy types')
@ -305,7 +312,8 @@ public static function table(Table $table): Table
TextColumn::make('retention_keep_last')
->label('Retention')
->suffix(' sets'),
->suffix(' sets')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('last_run_status')
->label('Last run status')
@ -347,7 +355,8 @@ public static function table(Table $table): Table
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
return $spec->iconColor ?? $spec->color;
}),
})
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('last_run_at')
->label('Last run')

View File

@ -17,6 +17,13 @@ protected function getHeaderActions(): array
];
}
protected function getTableEmptyStateActions(): array
{
return [
BackupScheduleResource::makeCreateAction(),
];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;

View File

@ -39,10 +39,12 @@ public function table(Table $table): Table
return $table
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey()))
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('Enqueued')
->dateTime(),
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('type')
->label('Type')
@ -91,6 +93,8 @@ public function table(Table $table): Table
})
->openUrlInNewTab(true),
])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading('No schedule runs yet')
->emptyStateDescription('Operation history will appear here after this schedule has been enqueued.');
}
}

View File

@ -18,6 +18,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\Rbac\UiEnforcement;
@ -119,18 +120,25 @@ public static function makeCreateAction(): Actions\CreateAction
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('name')->searchable(),
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupSetStatus))
->color(BadgeRenderer::color(BadgeDomain::BackupSetStatus))
->icon(BadgeRenderer::icon(BadgeDomain::BackupSetStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupSetStatus)),
Tables\Columns\TextColumn::make('item_count')->label('Items'),
Tables\Columns\TextColumn::make('created_by')->label('Created by'),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('created_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('item_count')->label('Items')->numeric()->sortable(),
Tables\Columns\TextColumn::make('created_by')->label('Created by')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('completed_at')->label('Completed')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('created_at')->label('Captured')->dateTime()->since()->sortable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TrashedFilter::make()

View File

@ -21,4 +21,11 @@ protected function getHeaderActions(): array
->visible(fn (): bool => $this->tableHasRecords()),
];
}
protected function getTableEmptyStateActions(): array
{
return [
BackupSetResource::makeCreateAction(),
];
}
}

View File

@ -13,6 +13,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -233,6 +234,8 @@ public function table(Table $table): Table
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->defaultSort('policy.display_name')
->paginated(TablePaginationProfiles::relationManager())
->columns([
Tables\Columns\TextColumn::make('policy.display_name')
->label('Item')
@ -267,7 +270,8 @@ public function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::PolicyRisk)),
Tables\Columns\TextColumn::make('policy_identifier')
->label('Policy ID')
->copyable(),
->copyable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('platform')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::Platform))
@ -309,9 +313,10 @@ public function table(Table $table): Table
}
return '—';
}),
Tables\Columns\TextColumn::make('captured_at')->dateTime(),
Tables\Columns\TextColumn::make('created_at')->since(),
})
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->headerActions([
@ -343,6 +348,11 @@ public function table(Table $table): Table
Actions\BulkActionGroup::make([
$bulkRemove,
])->label('More'),
])
->emptyStateHeading('No policies in this backup set')
->emptyStateDescription('Add policies to capture versions and assignments inside this backup set.')
->emptyStateActions([
$addPolicies->name('addPoliciesEmpty'),
]);
}

View File

@ -312,6 +312,7 @@ public static function table(Table $table): Table
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->columns([
TextColumn::make('name')
->searchable()

View File

@ -21,4 +21,13 @@ protected function getHeaderActions(): array
->visible(fn (): bool => $this->getTableRecords()->count() > 0),
];
}
protected function getTableEmptyStateActions(): array
{
return [
CreateAction::make()
->label('Create baseline profile')
->disabled(fn (): bool => ! BaselineProfileResource::canCreate()),
];
}
}

View File

@ -42,6 +42,8 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
public function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->columns([
Tables\Columns\TextColumn::make('tenant.name')
->label('Tenant')

View File

@ -133,6 +133,7 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('captured_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->columns([
TextColumn::make('id')
->label('Snapshot')
@ -159,7 +160,10 @@ public static function table(Table $table): Table
->actions([
ViewAction::make()->label('View'),
])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading('No baseline snapshots')
->emptyStateDescription('Capture a baseline snapshot to review evidence fidelity and compare tenants over time.')
->emptyStateIcon('heroicon-o-camera');
}
public static function infolist(Schema $schema): Schema

View File

@ -96,6 +96,7 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('display_name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->modifyQueryUsing(function (Builder $query): Builder {
$tenantId = Tenant::current()?->getKey();
@ -107,11 +108,12 @@ public static function table(Table $table): Table
->columns([
Tables\Columns\TextColumn::make('display_name')
->label('Name')
->searchable(),
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('entra_id')
->label('Entra ID')
->copyable()
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('type')
->label('Type')
->badge()
@ -189,7 +191,10 @@ public static function table(Table $table): Table
}),
])
->actions([])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading('No groups cached yet')
->emptyStateDescription('Sync groups for the current tenant to browse directory data here.')
->emptyStateIcon('heroicon-o-user-group');
}
public static function getEloquentQuery(): Builder

View File

@ -14,6 +14,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\Rbac\UiEnforcement;
use App\Support\Rbac\UiTooltips;
use App\Support\RedactionIntegrity;
@ -443,6 +444,10 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
Tables\Columns\TextColumn::make('status')
@ -466,10 +471,12 @@ public static function table(Table $table): Table
'meta' => 'gray',
default => 'gray',
})
->sortable(),
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('subject_display_name')
->label('Subject')
->placeholder('—')
->searchable()
->formatStateUsing(function (?string $state, Finding $record): ?string {
if (is_string($state) && trim($state) !== '') {
return $state;
@ -480,7 +487,7 @@ public static function table(Table $table): Table
return $fallback !== '' ? $fallback : null;
}),
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('due_at')
->label('Due')
->dateTime()
@ -968,7 +975,10 @@ public static function table(Table $table): Table
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
->apply(),
])->label('More'),
]);
])
->emptyStateHeading('No findings match this view')
->emptyStateDescription('Adjust the current filters or wait for the next detection run to surface new findings.')
->emptyStateIcon('heroicon-o-exclamation-triangle');
}
public static function getEloquentQuery(): Builder

View File

@ -216,10 +216,12 @@ public static function table(Table $table): Table
return $table
->defaultSort('last_seen_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->columns([
Tables\Columns\TextColumn::make('display_name')
->label('Name')
->searchable(),
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
@ -282,7 +284,10 @@ public static function table(Table $table): Table
? static::getUrl('view', ['record' => $record])
: null)
->actions([])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading('No inventory items')
->emptyStateDescription('Run an inventory sync to capture policy state for this tenant.')
->emptyStateIcon('heroicon-o-clipboard-document-list');
}
public static function getEloquentQuery(): Builder

View File

@ -11,6 +11,7 @@
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperateHub\OperateHubShell;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
@ -516,6 +517,10 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('status')
->badge()
@ -670,7 +675,10 @@ public static function table(Table $table): Table
->label('View run')
->url(fn (OperationRun $record): string => route('admin.operations.view', ['run' => (int) $record->getKey()])),
])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading('No operation runs found')
->emptyStateDescription('Queued, running, and completed operations will appear here when work is triggered in this scope.')
->emptyStateIcon('heroicon-o-queue-list');
}
public static function getPages(): array

View File

@ -20,6 +20,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -340,10 +341,16 @@ public static function table(Table $table): Table
// Full solution in Feature 005: Policy Lifecycle Management (soft delete)
$query->where('last_synced_at', '>', now()->subDays(7));
})
->defaultSort('display_name')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('display_name')
->label('Policy')
->searchable(),
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()
@ -390,7 +397,8 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('external_id')
->label('External ID')
->copyable()
->limit(32),
->limit(32)
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('last_synced_at')
->label('Last synced')
->dateTime()

View File

@ -15,4 +15,11 @@ protected function getHeaderActions(): array
PolicyResource::makeSyncAction(),
];
}
protected function getTableEmptyStateActions(): array
{
return [
PolicyResource::makeSyncAction(),
];
}
}

View File

@ -162,6 +162,7 @@ public function table(Table $table): Table
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('version_number', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->filters([])
->headerActions([])
->actions([
@ -170,6 +171,8 @@ public function table(Table $table): Table
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading('No versions captured')
->emptyStateDescription('Capture or sync this policy again to create version history entries.');
}
}

View File

@ -480,6 +480,8 @@ public static function table(Table $table): Table
->apply();
return $table
->defaultSort('captured_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->columns([
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
Tables\Columns\TextColumn::make('version_number')->sortable(),
@ -838,7 +840,10 @@ public static function table(Table $table): Table
$bulkRestoreVersions,
$bulkForceDeleteVersions,
])->label('More'),
]);
])
->emptyStateHeading('No policy versions')
->emptyStateDescription('Capture or sync policy snapshots to build a version history.')
->emptyStateIcon('heroicon-o-clock');
}
public static function getEloquentQuery(): Builder

View File

@ -17,6 +17,7 @@
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -457,6 +458,10 @@ public static function table(Table $table): Table
});
})
->defaultSort('display_name')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->recordUrl(fn (ProviderConnection $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('tenant.name')
@ -479,9 +484,9 @@ public static function table(Table $table): Table
return TenantResource::getUrl('view', ['record' => $tenant], panel: 'admin');
}),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(),
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('provider')->label('Provider')->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('entra_tenant_id')->label('Entra tenant ID')->copyable()->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_default')->label('Default')->boolean(),
Tables\Columns\TextColumn::make('status')
->label('Status')
@ -497,14 +502,14 @@ public static function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)),
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->toggleable(),
Tables\Columns\TextColumn::make('last_health_check_at')->label('Last check')->since()->sortable(),
Tables\Columns\TextColumn::make('last_error_reason_code')
->label('Last error reason')
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('last_error_message')
->label('Last error message')
->formatStateUsing(fn (?string $state): ?string => static::sanitizeErrorMessage($state))
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('tenant')

View File

@ -184,6 +184,7 @@ public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->recordUrl(fn (ReviewPack $record): string => static::getUrl('view', ['record' => $record]))
->columns([
Tables\Columns\TextColumn::make('status')

View File

@ -28,6 +28,7 @@
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -209,13 +210,20 @@ public static function getEloquentQuery(): Builder
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(TablePaginationProfiles::resource())
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('tenant_id')
->label('Tenant ID')
->copyable()
->searchable(),
->searchable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('environment')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::TenantEnvironment))
@ -231,7 +239,7 @@ public static function table(Table $table): Table
->sortable(),
Tables\Columns\TextColumn::make('domain')
->copyable()
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\IconColumn::make('is_current')
->label('Current')
->boolean(),
@ -250,7 +258,8 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->since(),
->since()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TrashedFilter::make()
@ -807,7 +816,10 @@ public static function table(Table $table): Table
->deselectRecordsAfterCompletion(),
])->label('More'),
])
->headerActions([]);
->headerActions([])
->emptyStateHeading('No tenants connected')
->emptyStateDescription('Add a tenant to start syncing inventory, policies, and provider health into this workspace.')
->emptyStateIcon('heroicon-o-building-office-2');
}
public static function infolist(Schema $schema): Schema

View File

@ -24,6 +24,8 @@ public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->columns([
Tables\Columns\TextColumn::make('user.email')
->label(__('User'))
@ -48,7 +50,7 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('source')
->badge()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')->since(),
Tables\Columns\TextColumn::make('created_at')->since()->sortable(),
])
->headerActions([
UiEnforcement::forTableAction(
@ -218,6 +220,8 @@ public function table(Table $table): Table
->destructive()
->apply(),
])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading(__('No tenant members'))
->emptyStateDescription(__('Add a member to delegate access inside this tenant.'));
}
}

View File

@ -17,6 +17,13 @@ protected function getHeaderActions(): array
];
}
protected function getTableEmptyStateActions(): array
{
return [
WorkspaceResource::makeCreateAction(),
];
}
private function tableHasRecords(): bool
{
return $this->getTableRecords()->count() > 0;

View File

@ -25,6 +25,8 @@ public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::relationManager())
->columns([
Tables\Columns\TextColumn::make('user.email')
->label(__('User'))
@ -46,7 +48,7 @@ public function table(Table $table): Table
Tables\Columns\TextColumn::make('role')
->badge()
->sortable(),
Tables\Columns\TextColumn::make('created_at')->since(),
Tables\Columns\TextColumn::make('created_at')->since()->sortable(),
])
->headerActions([
WorkspaceUiEnforcement::forTableAction(
@ -216,6 +218,8 @@ public function table(Table $table): Table
->destructive()
->apply(),
])
->bulkActions([]);
->bulkActions([])
->emptyStateHeading(__('No workspace members'))
->emptyStateDescription(__('Add a member to grant workspace access.'));
}
}

View File

@ -149,6 +149,8 @@ public static function makeCreateAction(): Actions\CreateAction
public static function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::resource())
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()

View File

@ -48,6 +48,7 @@ public function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return Tenant::query()
->with('workspace')
@ -60,10 +61,12 @@ public function table(Table $table): Table
->columns([
TextColumn::make('name')
->label('Tenant')
->searchable(),
->searchable()
->sortable(),
TextColumn::make('workspace.name')
->label('Workspace')
->searchable(),
->searchable()
->sortable(),
TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))

View File

@ -52,6 +52,7 @@ public function table(Table $table): Table
{
return $table
->defaultSort('name')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return Workspace::query()
->withCount([

View File

@ -75,6 +75,7 @@ public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return OperationRun::query()
->with(['tenant', 'workspace'])

View File

@ -58,6 +58,7 @@ public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return OperationRun::query()
->with(['tenant', 'workspace']);

View File

@ -74,6 +74,7 @@ public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return app(StuckRunClassifier::class)->apply(
OperationRun::query()

View File

@ -90,6 +90,7 @@ public function table(Table $table): Table
->heading('Workspaces')
->description('Current workspace ownership status.')
->defaultSort('name', 'asc')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return Workspace::query()
->withCount([

View File

@ -45,6 +45,7 @@ public function table(Table $table): Table
{
return $table
->defaultSort('recorded_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::customPage())
->query(function (): Builder {
return AuditLog::query()
->where(function (Builder $query): void {

View File

@ -11,8 +11,6 @@
class BaselineCompareNow extends Widget
{
protected static bool $isLazy = false;
protected string $view = 'filament.widgets.dashboard.baseline-compare-now';
/**

View File

@ -19,8 +19,6 @@
class RecentDriftFindings extends TableWidget
{
protected static bool $isLazy = false;
public function table(Table $table): Table
{
$tenant = Filament::getTenant();
@ -29,7 +27,8 @@ public function table(Table $table): Table
->heading('Recent Drift Findings')
->query($this->getQuery())
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
->paginated([10])
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
->columns([
TextColumn::make('short_id')
->label('ID')
@ -43,18 +42,22 @@ public function table(Table $table): Table
->tooltip(fn (Finding $record): ?string => $record->subject_display_name ?: null),
TextColumn::make('severity')
->badge()
->sortable()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
TextColumn::make('status')
->badge()
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
TextColumn::make('created_at')
->label('Created')
->sortable()
->since(),
])
->recordUrl(fn (Finding $record): ?string => $tenant instanceof Tenant

View File

@ -19,8 +19,6 @@
class RecentOperations extends TableWidget
{
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
@ -31,7 +29,8 @@ public function table(Table $table): Table
->heading('Recent Operations')
->query($this->getQuery())
->poll(fn (): ?string => ($tenant instanceof Tenant) && ActiveRuns::existForTenant($tenant) ? '10s' : null)
->paginated([10])
->defaultSort('created_at', 'desc')
->paginated(\App\Support\Filament\TablePaginationProfiles::widget())
->columns([
TextColumn::make('short_id')
->label('Run')
@ -40,23 +39,28 @@ public function table(Table $table): Table
->copyableState(fn (OperationRun $record): string => (string) $record->getKey()),
TextColumn::make('type')
->label('Operation')
->sortable()
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
->limit(40)
->tooltip(fn (OperationRun $record): string => OperationCatalog::label((string) $record->type)),
TextColumn::make('status')
->badge()
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunStatus))
->color(BadgeRenderer::color(BadgeDomain::OperationRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunStatus)),
TextColumn::make('outcome')
->badge()
->sortable()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::OperationRunOutcome))
->color(BadgeRenderer::color(BadgeDomain::OperationRunOutcome))
->icon(BadgeRenderer::icon(BadgeDomain::OperationRunOutcome))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::OperationRunOutcome)),
TextColumn::make('created_at')
->label('Started')
->sortable()
->since(),
])
->recordUrl(fn (OperationRun $record): ?string => $tenant instanceof Tenant

View File

@ -74,7 +74,8 @@ public function table(Table $table): Table
->when($existingPolicyIds !== [], fn (Builder $query) => $query->whereNotIn('id', $existingPolicyIds))
)
->deferLoading(! app()->runningUnitTests())
->paginated([25, 50, 100])
->defaultSort('display_name')
->paginated(\App\Support\Filament\TablePaginationProfiles::picker())
->defaultPaginationPageOption(25)
->searchable()
->striped()
@ -185,6 +186,8 @@ public function table(Table $table): Table
};
}),
])
->emptyStateHeading('No matching policies available')
->emptyStateDescription('Adjust the current filters or sync additional policies before adding them to this backup set.')
->bulkActions([
BulkAction::make('add_selected_to_backup_set')
->label('Add selected')

View File

@ -42,8 +42,8 @@ public function table(Table $table): Table
->queryStringIdentifier('entraGroupCachePicker')
->query($query)
->defaultSort('display_name')
->paginated([10, 25, 50])
->defaultPaginationPageOption(10)
->paginated(\App\Support\Filament\TablePaginationProfiles::picker())
->defaultPaginationPageOption(25)
->searchable()
->searchPlaceholder('Search groups…')
->deferLoading(! app()->runningUnitTests())
@ -59,12 +59,12 @@ public function table(Table $table): Table
->badge()
->state(fn (EntraGroup $record): string => $this->groupTypeLabel($this->groupType($record)))
->color(fn (EntraGroup $record): string => $this->groupTypeColor($this->groupType($record)))
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('entra_id')
->label('ID')
->formatStateUsing(fn (?string $state): string => filled($state) ? ('…'.substr($state, -8)) : '—')
->extraAttributes(['class' => 'font-mono'])
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('last_seen_at')
->label('Last seen')
->since()

View File

@ -32,6 +32,7 @@ public function table(Table $table): Table
{
return $table
->queryStringIdentifier('settingsCatalog'.Str::studly($this->context))
->defaultSort('definition')
->records(function (?string $search, int|string $page, int|string $recordsPerPage, ?string $sortColumn, ?string $sortDirection): LengthAwarePaginator {
$records = collect($this->settingsRows);
@ -75,7 +76,7 @@ public function table(Table $table): Table
$currentPage
);
})
->paginated([25, 50, 100])
->paginated(\App\Support\Filament\TablePaginationProfiles::picker())
->defaultPaginationPageOption(25)
->searchable()
->searchPlaceholder('Search definition/value…')
@ -141,7 +142,9 @@ public function table(Table $table): Table
->modalCancelActionLabel('Close')
->modalHeading(fn (array $record): string => (string) ($record['definition'] ?? 'Setting details'))
->modalContent(fn (array $record): View => view('filament.modals.settings-catalog-setting-details', ['record' => $record])),
]);
])
->emptyStateHeading('No settings match this view')
->emptyStateDescription('Clear the current search or reopen the source policy to inspect its catalog settings.');
}
public function render(): View

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Support\Filament;
final class TablePaginationProfiles
{
/**
* @return array<int>
*/
public static function resource(): array
{
return [25, 50, 100];
}
/**
* @return array<int>
*/
public static function relationManager(): array
{
return [10, 25, 50];
}
/**
* @return array<int>
*/
public static function widget(): array
{
return [10];
}
/**
* @return array<int>
*/
public static function picker(): array
{
return [25, 50, 100];
}
/**
* @return array<int|string>
*/
public static function customPage(): array
{
return [25, 50, 'all'];
}
}

View File

@ -0,0 +1,110 @@
# Filament Table Standard
## Standard
TenantPilot standardizes production Filament list surfaces with a convention-first model:
- Primary: searchable identifier or record title that anchors the row.
- Context: status, ownership, recency, and counts needed for a normal scan.
- Detail: technical IDs, secondary timestamps, verbose metadata, and low-frequency troubleshooting fields.
## Required Rules
- Every production table defines an explicit default sort unless a documented exception exists.
- Every production table provides a domain-specific empty state heading and description.
- General-purpose tables should expose seven or fewer columns by default unless density is part of the job.
- Primary identifiers should be searchable and sortable when query-safe.
- Technical identifiers and secondary metadata should be toggleable and hidden by default where practical.
- Resource lists in the critical set persist search, sort, and filters in session.
- Existing action surfaces, RBAC behavior, confirmations, and centralized badge semantics stay unchanged.
## Pagination Profiles
| Surface | Page sizes | Default |
|---|---|---|
| Resource | `25, 50, 100` | `25` |
| Relation manager | `10, 25, 50` | `10` |
| Widget | `10` | `10` |
| Picker | `25, 50, 100` | `25` |
| Custom page | `25, 50, all` | `25` unless a page overrides it explicitly |
Implementation uses [TablePaginationProfiles.php](/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/Support/Filament/TablePaginationProfiles.php).
## Timestamp, Null, and ID Rules
- Prefer `->since()` for scan-first timestamps unless exact chronology is the primary task.
- Use `—` for missing values unless the domain needs a more specific placeholder.
- Long identifiers should remain copyable and readable without dominating the default layout.
- Prefer monospaced styling and tooltips for truncated technical identifiers.
## Review Checklist
- Primary column is obvious and query-safe.
- Default sort matches the tables operational purpose.
- Empty-state copy explains what the list is waiting for.
- Hidden detail can be revealed in one toggle or one drill-in.
- Pagination profile matches the table class.
- Critical resource lists declare session persistence.
- No destructive action lost its confirmation or authorization guard.
## Rollout Audit
### Persistence Surfaces
The following resource lists must persist search, sort, and filters in session:
- `TenantResource`
- `PolicyResource`
- `BackupSetResource`
- `BackupScheduleResource`
- `ProviderConnectionResource`
- `FindingResource`
- `OperationRunResource`
### Documented Exceptions
- `RecentDriftFindings` and `RecentOperations` do not add table search because dashboard widgets are glance surfaces, not investigative workbenches.
- `Directory/Workspaces` keeps computed health and recent-failure metrics non-sortable and non-searchable because those values are derived per row.
- `InventoryCoverage` uses the custom-page pagination profile but keeps the broader `all` option and a `50`-row default because operators sometimes need a full matrix pass.
- Picker tables keep workflow-local search only; they do not persist state in session.
### Surface Inventory
| Surface | Class | Pagination | Default sort | Empty state | Persistence | Notes |
|---|---|---|---|---|---|---|
| Tenant list | `resource` | resource | `name asc` | yes | yes | Workspace-scoped create CTA remains in list page |
| Policy list | `resource` | resource | `display_name asc` | yes | yes | Sync CTA remains list-local |
| Backup set list | `resource` | resource | `created_at desc` | yes | yes | Create CTA remains list-local |
| Backup schedule list | `resource` | resource | `next_run_at asc` | yes | yes | Create CTA remains list-local |
| Provider connections | `resource` | resource | `display_name asc` | yes | yes | Empty-state CTA remains tenant-aware |
| Findings | `resource` | resource | `created_at desc` | yes | yes | Open filter remains the default |
| Monitoring operations | `resource-backed page` | resource | `created_at desc` | yes | yes | Canonical operations view uses `OperationRunResource::table()` |
| Entra groups | `resource` | resource | `display_name asc` | yes | no | Directory browse remains read-only |
| Alert deliveries | `resource` | resource | `id desc` | yes | no | Delivery history stays read-only |
| Alert rules | `resource` | resource | `name asc` | yes | no | Rule actions remain explicit per row |
| Alert destinations | `resource` | resource | `name asc` | yes | no | Destination test/send actions remain unchanged |
| Baseline profiles | `resource` | resource | `name asc` | yes | no | Create CTA remains list-local |
| Baseline snapshots | `resource` | resource | `captured_at desc` | yes | no | Snapshot browsing remains read-only |
| Inventory items | `resource` | resource | `last_seen_at desc` | yes | no | Scan-first recency view |
| Policy versions resource | `resource` | resource | `captured_at desc` | yes | no | Version history remains inspectable and immutable |
| Review packs | `resource` | resource | `created_at desc` | yes | no | Review workflow actions unchanged |
| Workspace resource | `resource` | resource | `name asc` | yes | no | Workspace create CTA remains list-local |
| Backup items | `relation_manager` | relation manager | `policy.display_name asc` | yes | no | Action semantics unchanged |
| Policy versions | `relation_manager` | relation manager | `version_number desc` | yes | no | Existing relation query preserved |
| Backup schedule operation runs | `relation_manager` | relation manager | `created_at desc` | yes | no | Existing record view preserved |
| Tenant memberships | `relation_manager` | relation manager | `created_at desc` | yes | no | Role management unchanged |
| Workspace memberships | `relation_manager` | relation manager | `created_at desc` | yes | no | Role management unchanged |
| Baseline tenant assignments | `relation_manager` | relation manager | `created_at desc` | yes | no | Assignment action unchanged |
| Inventory coverage | `custom_page` | custom page | `label asc` | yes | no | Keeps `all` pagination option |
| System directory tenants | `custom_page` | custom page | `name asc` | yes | no | Search stays on meaningful identity fields |
| System directory workspaces | `custom_page` | custom page | `name asc` | yes | no | Computed metrics remain exceptions |
| Ops runs | `custom_page` | custom page | `id desc` | yes | no | Platform triage actions unchanged |
| Ops failures | `custom_page` | custom page | `id desc` | yes | no | Platform triage actions unchanged |
| Ops stuck | `custom_page` | custom page | `id desc` | yes | no | Platform triage actions unchanged |
| Access logs | `custom_page` | custom page | `recorded_at desc` | yes | no | Read-only operational surface |
| Repair workspace owners | `custom_page` | custom page | `name asc` | yes | no | Repair action unchanged |
| Recent drift findings | `widget` | widget | `created_at desc` | yes | no | No search by design |
| Recent operations | `widget` | widget | `created_at desc` | yes | no | No search by design |
| Backup set policy picker | `picker` | picker | `display_name asc` | yes | no | Workflow-local search only |
| Entra group picker | `picker` | picker | `display_name asc` | yes | no | Workflow-local search only |
| Settings catalog table | `picker` | picker | `definition asc` | yes | no | Workflow-local search only |

View File

@ -0,0 +1,36 @@
# Specification Quality Checklist: Filament Table UX Standardization & List Consistency
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-08
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Validated after the first full draft; no clarification markers remain.
- The spec names Filament because the product requirement is explicitly about Filament table surfaces, but it avoids code-level method prescriptions and keeps the requirements centered on user-visible behavior.
- The Action Surface Contract is preserved by treating actions as table-local and explicitly out of scope for redesign in this spec.

View File

@ -0,0 +1,190 @@
openapi: 3.1.0
info:
title: Filament Table State Standardization Contract
version: 1.0.0
description: |
Page-level contract for standardized Filament list surfaces affected by Spec 125.
This feature does not add public APIs. It formalizes the expected query-state
parameters and page behaviors for resource, tenant-scoped, and system-scoped
list surfaces that use native Filament tables.
paths:
/admin/{listSlug}:
get:
summary: Render a standardized workspace-scoped Filament list surface
operationId: getWorkspaceScopedListSurface
tags:
- Filament Table UX
parameters:
- $ref: '#/components/parameters/ListSlug'
- $ref: '#/components/parameters/TableSearch'
- $ref: '#/components/parameters/TableSortColumn'
- $ref: '#/components/parameters/TableSortDirection'
- $ref: '#/components/parameters/TableRecordsPerPage'
- $ref: '#/components/parameters/Page'
responses:
'200':
description: Standardized list page rendered successfully
content:
text/html:
schema:
type: string
'403':
description: Actor is a member of the scope but lacks the required capability for the page or exposed action
'404':
description: Workspace scope is unavailable or the actor is not entitled to the requested scope
/admin/t/{tenant}/{listSlug}:
get:
summary: Render a standardized tenant-scoped Filament list surface
operationId: getTenantScopedListSurface
tags:
- Filament Table UX
parameters:
- name: tenant
in: path
required: true
description: Tenant identifier resolved by the existing Filament tenant route binding
schema:
type: string
- $ref: '#/components/parameters/ListSlug'
- $ref: '#/components/parameters/TableSearch'
- $ref: '#/components/parameters/TableSortColumn'
- $ref: '#/components/parameters/TableSortDirection'
- $ref: '#/components/parameters/TableRecordsPerPage'
- $ref: '#/components/parameters/Page'
responses:
'200':
description: Standardized tenant-scoped list page rendered successfully
content:
text/html:
schema:
type: string
'403':
description: Tenant member lacks the capability required for the page or exposed action
'404':
description: Tenant or workspace context is unavailable or the actor is not entitled to the tenant scope
/system/{listSlug}:
get:
summary: Render a standardized platform-scoped Filament list surface
operationId: getSystemScopedListSurface
tags:
- Filament Table UX
parameters:
- $ref: '#/components/parameters/ListSlug'
- $ref: '#/components/parameters/TableSearch'
- $ref: '#/components/parameters/TableSortColumn'
- $ref: '#/components/parameters/TableSortDirection'
- $ref: '#/components/parameters/TableRecordsPerPage'
- $ref: '#/components/parameters/Page'
responses:
'200':
description: Standardized platform list page rendered successfully
content:
text/html:
schema:
type: string
'403':
description: Platform user lacks the capability required for the page or exposed action
'404':
description: The actor is not entitled to the platform scope
components:
parameters:
ListSlug:
name: listSlug
in: path
required: true
description: |
Logical list-surface identifier. Examples include resource list pages,
system directory tables, operations pages, or custom picker tables.
schema:
type: string
TableSearch:
name: tableSearch
in: query
required: false
description: Free-text search across the table's approved searchable fields
schema:
type: string
TableSortColumn:
name: tableSortColumn
in: query
required: false
description: Active sortable column for the table surface
schema:
type: string
TableSortDirection:
name: tableSortDirection
in: query
required: false
description: Active table sort direction
schema:
type: string
enum:
- asc
- desc
TableRecordsPerPage:
name: tableRecordsPerPage
in: query
required: false
description: Selected page-size option from the surface's pagination profile
schema:
oneOf:
- type: integer
minimum: 5
- type: string
enum:
- all
Page:
name: page
in: query
required: false
description: Current paginator page for the table surface
schema:
type: integer
minimum: 1
schemas:
StandardizedTableBehavior:
type: object
required:
- defaultSort
- emptyState
- paginationProfile
properties:
defaultSort:
type: object
properties:
column:
type: string
direction:
type: string
enum:
- asc
- desc
persistence:
type: object
properties:
search:
type: boolean
sort:
type: boolean
filters:
type: boolean
emptyState:
type: object
required:
- heading
- description
properties:
heading:
type: string
description:
type: string
hasAuthorizedAction:
type: boolean
paginationProfile:
type: string
enum:
- resource
- relation_manager
- widget
- picker
- custom_page

View File

@ -0,0 +1,111 @@
# Data Model: Filament Table UX Standardization & List Consistency
## Overview
This feature does not introduce new persistent storage. It defines a conceptual model for how production Filament tables are classified, reviewed, and rolled out.
## Entities
### TableSurface
- Purpose: Represents one production Filament-backed list surface that must be brought into the standard.
- Source: Existing resources, relation managers, widgets, custom pages, and Livewire table components.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `surface_key` | string | yes | Stable identifier for the table surface, typically derived from class name and route role |
| `surface_type` | enum(`resource`,`relation_manager`,`widget`,`custom_page`,`picker`) | yes | Classification used for rollout rules and pagination profile |
| `plane` | enum(`admin`,`tenant`,`system`) | yes | Authorization plane in which the table is rendered |
| `route_scope` | enum(`workspace`,`tenant`,`canonical_view`) | yes | Scope used to reason about entitlement and visible context |
| `class_name` | string | yes | Backing PHP class for the table surface |
| `primary_route` | string | yes | Main route or route pattern where the table is reached |
| `data_ownership` | enum(`workspace_owned`,`tenant_owned`,`mixed`,`runtime_only`) | yes | Ownership context of the records displayed |
| `criticality` | enum(`critical`,`standard`,`special_case`) | yes | Rollout priority and review intensity |
| `has_empty_state` | boolean | yes | Whether the surface currently defines a domain-specific empty state |
| `has_default_sort` | boolean | yes | Whether the surface currently defines an explicit default sort |
| `has_persistence` | boolean | yes | Whether search, sort, and filters persist across refresh in the same session |
| `query_risk` | enum(`low`,`medium`,`high`) | yes | Risk level for adding or changing sort/search behavior |
| `exception_reason` | string nullable | no | Documented rationale when a standard rule is intentionally not applied |
#### Validation Rules
- `surface_key` must be unique across the audited table inventory.
- `surface_type`, `plane`, `route_scope`, and `criticality` must come from the controlled enums above.
- `exception_reason` is required when a mandatory standard rule is not applied.
- `query_risk` must be set before adding sort or search behavior to relation-backed or computed fields.
### TableBehaviorProfile
- Purpose: Captures the standardized behavior a table surface should implement.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `default_sort_column` | string nullable | no | Default sort column when the surface has one |
| `default_sort_direction` | enum(`asc`,`desc`) nullable | no | Default sort direction |
| `search_fields` | list<string> | yes | User-facing searchable dimensions |
| `visible_default_column_limit` | integer | yes | Target number of visible default columns |
| `persistence_profile` | enum(`resource_list`,`optional`,`none`) | yes | Whether native table state persistence is required |
| `pagination_profile` | enum(`resource`,`relation_manager`,`widget`,`picker`,`custom_page`) | yes | Page-size rules for the surface class |
| `empty_state_required` | boolean | yes | Whether a domain-specific empty state is required |
| `timestamp_profile` | enum(`relative_default`,`absolute_required`,`mixed`) | yes | Default timestamp rendering expectation |
| `null_placeholder` | string | yes | Standard placeholder for missing values |
#### Relationships
- One `TableSurface` has one `TableBehaviorProfile`.
- A `TableBehaviorProfile` is implemented through native Filament table methods on the owning table class.
### ColumnProfile
- Purpose: Represents one meaningful column in a standardized table.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `column_key` | string | yes | Stable column identifier matching the table definition |
| `tier` | enum(`primary`,`context`,`detail`) | yes | Conceptual visibility tier |
| `is_default_visible` | boolean | yes | Whether the column is visible in the default table surface |
| `is_toggleable` | boolean | yes | Whether the column can be toggled by the user |
| `is_hidden_by_default` | boolean | yes | Whether toggleable detail starts hidden |
| `is_sortable` | boolean | yes | Whether native sorting is enabled |
| `is_searchable` | boolean | yes | Whether native search is enabled |
| `rendering_type` | enum(`text`,`badge`,`icon`,`timestamp`,`count`,`identifier`,`boolean`,`custom`) | yes | Rendering category used for consistency review |
| `query_risk` | enum(`low`,`medium`,`high`) | yes | Risk of enabling or changing search/sort on the column |
#### Validation Rules
- Every table must have at least one `primary` column.
- `detail` columns should normally be toggleable and hidden by default unless a documented exception exists.
- `is_searchable` and `is_sortable` must remain false when the query risk is unacceptable and no mitigation exists.
### TableException
- Purpose: Captures a justified deviation from the standard.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `surface_key` | string | yes | Referenced table surface |
| `rule_key` | string | yes | Standard rule being exempted |
| `reason` | string | yes | Human-readable explanation for the exception |
| `approved_phase` | enum(`foundation`,`critical_rollout`,`broad_rollout`,`hardening`) | yes | Phase in which the exception was recorded |
| `review_action` | string nullable | no | Follow-up needed to remove or reduce the exception |
## State Transitions
- Inventory: audit each production `TableSurface` and record its current `TableBehaviorProfile` and `ColumnProfile` set.
- Classification: assign each relevant column to `primary`, `context`, or `detail`.
- Standardization: apply the target behavior profile to the table using native Filament configuration.
- Exception handling: record a `TableException` where performance, scope, or product clarity prevents full compliance.
- Hardening: revisit medium- and high-risk exceptions after first-wave rollout and query review.
## Notes
- No migrations, new Eloquent models, or persistent tables are introduced by this feature.
- The model exists to support planning, phased rollout, and future review consistency rather than runtime storage.

View File

@ -0,0 +1,119 @@
# Implementation Plan: Filament Table UX Standardization & List Consistency
**Branch**: `125-table-ux-standardization` | **Date**: 2026-03-08 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/125-table-ux-standardization/spec.md`
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/125-table-ux-standardization/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Standardize the products Filament list surfaces with a convention-first, native-Filament rollout that keeps table behavior explicit in existing `table()` definitions, adds resource-list state persistence, normalizes default sort and empty-state behavior, and reduces default column sprawl through a documented Primary / Context / Detail model. The implementation will avoid a heavy helper framework, preserve existing action and RBAC behavior, keep centralized badge semantics intact, and allow only a tiny pagination-profile helper early in the rollout where the reuse is purely mechanical and does not hide per-surface table logic.
## Technical Context
**Language/Version**: PHP 8.4.15
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Tailwind CSS 4, existing `BadgeCatalog` / `BadgeRenderer`, existing UI enforcement helpers, existing Filament resources, relation managers, widgets, and Livewire table components
**Storage**: PostgreSQL remains unchanged; this feature is presentation-layer and behavior-layer only
**Testing**: Pest 4 feature tests for critical Filament list surfaces, relation manager coverage where applicable, targeted regression tests for persistence and empty states, plus manual QA for layout calmness and overflow edge cases
**Target Platform**: Laravel Sail local development, Filament admin and system panels in a web application deployed through Dokploy
**Project Type**: web application
**Performance Goals**: No material query regression on existing hot tables; no new obvious N+1 patterns; key list surfaces remain responsive under enterprise-sized datasets; resource-list refresh preserves state without custom client logic
**Constraints**: No new plugin dependency, no macro-first strategy, no heavy base table abstraction, no action redesign, no general search redesign, no panel-wide CSS width hacks, no authorization behavior changes, no new asset pipeline work
**Scale/Scope**: Approximately 36 production Filament table surfaces across resources, relation managers, widgets, custom pages, system pages, and picker tables, with a first-wave focus on critical resource lists plus the most overloaded relation-manager hotspot. Query-risk system tables remain part of the broader rollout and must be aligned conservatively with documented exceptions where needed.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Gate | Pre-Research | Post-Design | Notes |
|------|--------------|-------------|-------|
| Inventory-first / snapshots-second | PASS | PASS | The feature changes only list presentation and interaction patterns on existing inventory-, snapshot-, and operations-related surfaces; it does not redefine storage semantics. |
| Read/write separation | PASS | PASS | No new write workflows are introduced. Existing destructive or operational actions remain table-local and unchanged in behavior. |
| Graph contract path | N/A | N/A | No Graph calls or contract registry changes are part of this feature. |
| Deterministic capabilities | PASS | PASS | Existing capability resolution and UI enforcement remain the source of truth; the rollout does not introduce raw capability strings or role checks. |
| Workspace + tenant isolation | PASS | PASS | The feature spans `/admin`, `/admin/t/{tenant}/...`, and `/system`, but keeps each surface inside its current entitlement boundary. |
| RBAC-UX authorization semantics | PASS | PASS | Non-member 404 and member-without-capability 403 semantics remain unchanged; empty-state CTAs must remain capability-gated. |
| Run observability / Ops-UX | N/A | N/A | No new long-running, queued, or remote work is introduced. Existing operation actions remain governed by current `OperationRun` patterns outside this features scope. |
| Data minimization and safe logging | PASS | PASS | The rollout changes table rendering only and does not add new payload logging or persistence. |
| BADGE-001 centralized badge semantics | PASS | PASS | Existing `BadgeCatalog` / `BadgeRenderer` infrastructure stays authoritative. The standard does not create table-local badge mappings. |
| Filament Action Surface Contract | PASS | PASS | Actions remain explicit per surface. The rollout standardizes list consistency without redesigning header, row, bulk, or view actions. |
| UX-001 table obligations | PASS | PASS | The feature directly strengthens empty states, search/sort behavior, and table clarity while leaving create/edit/view layouts untouched. |
| Filament v5 / Livewire v4 compliance | PASS | PASS | The repo already runs Filament v5 on Livewire v4, which remains unchanged by this plan. |
| Panel provider registration | PASS | PASS | No panel provider changes are required; Laravel 11+ panel registration remains in `bootstrap/providers.php`. |
| Global search safety for first-wave resources | PASS | PASS | Tenant, Policy, BackupSet, BackupSchedule, and Finding resources already have View or Edit pages. ProviderConnectionResource and OperationRunResource are explicitly not globally searchable. |
| Asset strategy | PASS | PASS | No new assets are required. Existing deployment expectations, including `php artisan filament:assets`, remain unchanged. |
## Implementation Notes
- The first-wave critical resource surfaces are `TenantResource`, `PolicyResource`, `BackupSetResource`, `BackupScheduleResource`, `ProviderConnectionResource`, `FindingResource`, `OperationRunResource`, and the most overloaded relation-backed table `BackupItemsRelationManager`.
- The current codebase already uses native Filament table features such as `defaultSort()`, `toggleable()`, `emptyStateHeading()`, `emptyStateDescription()`, `emptyStateActions()`, and `paginated([...])`, but session persistence methods are effectively absent and there is no existing shared `StandardTableDefaults` helper. A narrow pagination-profile helper is acceptable as an early foundational aid because it captures only repeated page-size options and keeps search, sort, visibility, and empty-state behavior explicit in each surface.
- `app/Filament/System/Pages/Directory/Workspaces.php` is a confirmed query-risk hotspot because it computes health and recent-failure metrics per row; any new sort/search behavior there must remain conservative.
- `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` is a confirmed density and action-heavy hotspot; the rollout must calm the default surface without disturbing its existing operational actions.
- Destructive actions are not redesigned here. Existing destructive actions must continue to use `->requiresConfirmation()` and current UI enforcement helpers.
## Project Structure
### Documentation (this feature)
```text
specs/125-table-ux-standardization/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── filament-table-state.openapi.yaml
└── tasks.md
```
### Source Code (repository root)
```text
app/
├── Filament/
│ ├── Pages/
│ │ ├── InventoryCoverage.php
│ │ └── Monitoring/Operations.php
│ ├── Resources/
│ │ ├── TenantResource.php
│ │ ├── PolicyResource.php
│ │ ├── BackupSetResource.php
│ │ ├── BackupScheduleResource.php
│ │ ├── ProviderConnectionResource.php
│ │ ├── FindingResource.php
│ │ ├── OperationRunResource.php
│ │ ├── BackupSetResource/RelationManagers/BackupItemsRelationManager.php
│ │ ├── PolicyResource/RelationManagers/VersionsRelationManager.php
│ │ └── Workspaces/WorkspaceResource.php
│ ├── System/Pages/
│ │ ├── Directory/Tenants.php
│ │ ├── Directory/Workspaces.php
│ │ ├── Ops/Runs.php
│ │ ├── Ops/Failures.php
│ │ ├── Ops/Stuck.php
│ │ └── Security/AccessLogs.php
│ └── Widgets/
│ └── Dashboard/
│ ├── RecentDriftFindings.php
│ └── RecentOperations.php
├── Livewire/
│ ├── BackupSetPolicyPickerTable.php
│ ├── EntraGroupCachePickerTable.php
│ └── SettingsCatalogSettingsTable.php
└── Support/
└── Badges/
├── BadgeCatalog.php
└── BadgeRenderer.php
tests/
├── Feature/Filament/
└── Feature/Rbac/
```
**Structure Decision**: Keep the existing single Laravel application structure and update table behavior at the current surface boundaries. If a tiny shared helper emerges as justified during implementation, it should live under `app/Support/Filament/` or an equally local support namespace and remain limited to mechanical repetition such as pagination profiles.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None | Not applicable | The design stays within the constitution and the specs anti-abstraction constraints |

View File

@ -0,0 +1,56 @@
# Quickstart: Filament Table UX Standardization & List Consistency
## Goal
Standardize production Filament list surfaces so they are calmer by default, sortable where users expect it, persistent on key resource lists, and consistent in empty states, pagination, and detail visibility.
## Foundation Steps
1. Document the repo table standard and review checklist in the feature artifacts.
2. Confirm the audited table inventory and classify each surface by type: resource, relation manager, widget, custom page, or picker.
3. Define the pagination profiles that will apply across those surface types.
4. Confirm that resource-list search, sort, and filter persistence will use native Filament session persistence methods.
5. Identify first-wave critical tables and mark any known query-risk columns before changing sort or search behavior.
## First-Wave Implementation Steps
1. Update the critical resource tables first: tenant, policy, backup set, backup schedule, provider connection, finding, and other designated high-value list surfaces.
2. For each critical table, assign columns to Primary, Context, and Detail tiers and reduce default visibility to a calm working set.
3. Add or refine explicit default sort behavior and searchable primary identifiers.
4. Hide technical IDs and secondary timestamps by default where practical, while keeping them accessible on demand.
5. Add or refine domain-specific empty states and align page-size options with the chosen pagination profile.
6. Enable native persistence for search, sort, and filters on resource lists.
7. Preserve existing action surfaces, badge semantics, tenancy boundaries, and authorization behavior.
## Broad Rollout Steps
1. Apply the same standard to remaining resources, relation managers, widgets, custom pages, and picker tables.
2. Record documented exceptions for tables where relation-backed or computed columns should not become sortable or searchable.
3. Revisit tables with wide layouts and move lower-value columns into detail before considering any cosmetic workaround.
4. Finish with a pass that normalizes timestamp, placeholder, and ID rendering across remaining outliers.
## Verification
### Automated
```bash
vendor/bin/sail up -d
vendor/bin/sail artisan test --compact tests/Feature/Filament
vendor/bin/sail artisan test --compact tests/Feature/Rbac
vendor/bin/sail bin pint --dirty --format agent
```
### Manual
1. Open each first-wave critical table and verify that the default visible columns feel calmer and remain informative.
2. Search, sort, and filter a resource list, refresh the page, and confirm the same state is preserved.
3. Verify that empty tables or zero-result states show clear domain-specific copy and only authorized next steps.
4. Confirm that long IDs and timestamps remain accessible without causing layout breakage.
5. Review system pages and cross-tenant views to ensure enough visible context remains to avoid ambiguity.
6. Re-check any relation-backed or computed columns that were left as documented exceptions.
## Rollback
- Revert the affected table definitions and any tiny shared pagination helper introduced during the rollout.
- Remove or narrow any standardization changes that create unacceptable query cost, keeping documented exceptions in place.
- No database rollback is required unless a later implementation phase introduces unrelated schema changes, which this spec does not plan.

View File

@ -0,0 +1,65 @@
# Research: Filament Table UX Standardization & List Consistency
## Decision 1: Use native Filament table configuration as the primary implementation path
- Decision: Standardize behavior directly in each tables existing `table()` definition and page-level empty-state hooks, using native Filament methods as the default approach.
- Rationale: The repo already defines table behavior locally across resources, relation managers, widgets, system pages, and Livewire picker tables. Keeping the standard inline preserves clarity, aligns with the specs convention-first goal, and avoids introducing a second configuration language on top of Filament.
- Alternatives considered:
- Build a generic table DSL with primary/context/detail declarations: rejected because it would hide normal Filament behavior and create a parallel framework to maintain.
- Make macros the default rollout path: rejected because the current inconsistencies are mostly judgment and information-architecture issues, not missing framework capability.
## Decision 2: Keep shared support intentionally tiny and mechanical
- Decision: Do not introduce a large shared base class or trait hierarchy. Only allow tiny shared support where duplication is purely mechanical, with pagination-profile helpers as the most likely candidate.
- Rationale: The workspace currently has no shared `StandardTableDefaults` or equivalent helper. Introducing a broad helper layer at the same time as a repo-wide cleanup would increase migration risk and make reviews harder.
- Alternatives considered:
- Add a `StandardTableDefaults` trait and force every table through it: rejected because it would centralize too many domain-specific decisions and make exceptions harder to reason about.
- Keep every pagination and persistence setting fully duplicated forever: rejected because a very small helper for page-size arrays may be justified once the first rollout wave proves the pattern is stable.
## Decision 3: Treat resource-list persistence as mandatory and relation-manager persistence as optional
- Decision: Enable session persistence for search, sort, and filters on resource list tables in the first rollout wave. Leave relation managers, widgets, picker tables, and custom pages on an opt-in basis where the behavior fits naturally.
- Rationale: The audit gap is strongest on resource lists, and Filament-native session persistence maps directly to that need. Extending persistence to every table surface immediately would expand scope and create more state-management edge cases than the spec requires.
- Alternatives considered:
- Add persistence to every table surface immediately: rejected because it increases rollout complexity without matching the strongest user pain first.
- Skip persistence and rely only on query-string state: rejected because the spec explicitly targets keeping list context across refreshes.
## Decision 4: Use a documented Primary / Context / Detail model rather than code-level column metadata
- Decision: Express Primary, Context, and Detail as a repo review convention backed by examples, not as a new code abstraction.
- Rationale: The missing consistency is mostly a design-review problem. A documented tier model gives reviewers and implementers a common language while letting each table remain explicit and readable.
- Alternatives considered:
- Add a `primaryColumn()` / `detailColumn()` API wrapper: rejected because it would obscure normal Filament column configuration and encourage over-abstraction.
- Leave visibility choices fully ad hoc: rejected because that is the exact drift pattern the spec is meant to stop.
## Decision 5: Standardize timestamps, nulls, and IDs using native column methods
- Decision: Use native Filament column behavior for relative timestamps, placeholders, toggle-hidden detail fields, copyable identifiers, truncation, and tooltips instead of custom renderers wherever possible.
- Rationale: The repo already uses `since()`, `dateTime()`, `toggleable()`, `copyable()`, and empty-state APIs in several places. Extending those patterns is lower risk than inventing custom rendering helpers.
- Alternatives considered:
- Introduce custom Blade column views for standard timestamp and ID rendering: rejected because it would be more fragile and harder to apply consistently across many surfaces.
- Leave timestamp and null formatting untouched during rollout: rejected because inconsistent rendering is one of the audited usability defects.
## Decision 6: Preserve existing badge, action, and RBAC architecture
- Decision: Do not redesign actions, badge mappings, or authorization mechanics as part of this feature. Keep actions and empty-state CTAs table-local, preserve centralized badge rendering through `BadgeCatalog` and `BadgeRenderer`, and maintain existing UI enforcement helpers.
- Rationale: The repo already has centralized badge infrastructure and explicit action-level UI enforcement patterns. The spec explicitly excludes action redesign, and mixing that work into the rollout would create unnecessary risk.
- Alternatives considered:
- Standardize row actions and header actions in the same feature: rejected because actions are comparatively consistent and would create avoidable scope creep.
- Rebuild badges as part of a broader “table facelift”: rejected because BADGE-001 requires centralized semantics and the current badge layer already exists.
## Decision 7: Use performance exceptions deliberately and document them per table
- Decision: Any new sort or search behavior on relation-backed or computed columns requires explicit query review, and some tables may keep documented exceptions instead of forcing full compliance.
- Rationale: Direct code inspection already shows query-sensitive surfaces. `app/Filament/System/Pages/Directory/Workspaces.php` computes health and recent failures per row, and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` already mixes operational actions with a dense relation-backed list. The standard must not worsen those hotspots.
- Alternatives considered:
- Force every meaningful-looking column to become sortable or searchable: rejected because that would create hidden N+1 or aggregate query regressions.
- Exclude risk tables from the rollout entirely: rejected because the spec requires broad alignment, but exceptions can be documented where needed.
## Decision 8: Roll out by table class and business criticality, not alphabetically
- Decision: Implement the standard in phases: documentation and baseline, first-wave critical resource tables, then remaining resources and relation managers, then widgets, custom pages, and picker tables, followed by performance hardening.
- Rationale: The current table surface spans roughly three dozen screens with uneven complexity. A phased rollout lets the project prove the standard on high-value tables before applying it repo-wide.
- Alternatives considered:
- Update every table in one large pass: rejected because it would be hard to review and too risky for query behavior.
- Limit the effort to a small set of flagship resources: rejected because the spec is explicitly repo-wide and aims to stop future drift.

View File

@ -0,0 +1,140 @@
# Feature Specification: Filament Table UX Standardization & List Consistency
**Feature Branch**: `125-table-ux-standardization`
**Created**: 2026-03-08
**Status**: Draft
**Input**: User description: "Spec 125 — Filament Table UX Standardization & List Consistency"
## Spec Scope Fields *(mandatory)*
- **Scope**: workspace
- **Primary Routes**: All Filament table surfaces under `/admin`, `/admin/t/{tenant}/...`, and `/system` that render resource lists, relation manager tables, table widgets, custom table pages, or picker tables
- **Data Ownership**: Both workspace-owned and tenant-owned records are affected only at the presentation and interaction layer; this feature does not redefine underlying ownership or introduce new record types
- **RBAC**: Existing workspace membership, tenant membership, plane separation, and capability gates remain unchanged; the standard applies only within each surfaces current authorization boundaries
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Scan Core Lists Predictably (Priority: P1)
As an operator moving between major product lists, I can rely on a consistent default table structure so I can find, sort, and compare records without relearning each screen.
**Why this priority**: Predictable list behavior is the core value of the feature. If major tables still feel inconsistent, the repo-wide standardization effort fails even if individual tables improve cosmetically.
**Independent Test**: Can be fully tested by updating one critical list page to follow the standard and verifying that its primary identifier is searchable and sortable, low-value technical detail is not dominant by default, and the table has a domain-specific empty state.
**Acceptance Scenarios**:
1. **Given** a critical product table with multiple records, **When** the user opens the page, **Then** the table presents a calm default view with a clear primary identifier, meaningful contextual columns, and technical detail kept secondary.
2. **Given** a populated critical table, **When** the user sorts by the primary identifier or recency field, **Then** the list responds in a way that matches the tables domain purpose.
---
### User Story 2 - Keep List Context Across Refresh (Priority: P2)
As a user investigating records over several page loads, I can refresh or return to key list pages without losing my search, sort, or filter context.
**Why this priority**: Losing table state creates repeated work and breaks operational flow. Persistence is one of the clearest gaps identified in the audit and materially affects day-to-day usability.
**Independent Test**: Can be tested by applying search, sort, and filters on a resource list, refreshing the page, and confirming that the list reopens in the same state.
**Acceptance Scenarios**:
1. **Given** a resource list with an active search term, sort order, and filter selection, **When** the user refreshes the page, **Then** the same list context remains active.
2. **Given** a user returns to a key resource list after navigating away, **When** the page loads again in the same session, **Then** the previously chosen list state is preserved.
---
### User Story 3 - Reveal Detail Only When Needed (Priority: P3)
As an advanced operator, I can access technical fields such as identifiers and timestamps when needed without having those fields dominate every list by default.
**Why this priority**: Enterprise operators still need detail, but the product should present it on demand instead of overwhelming the default table surface.
**Independent Test**: Can be tested by opening a standardized table, confirming that technical detail is hidden by default, and then exposing the detail without losing access to the records primary context.
**Acceptance Scenarios**:
1. **Given** a standardized list that contains technical identifiers or low-frequency metadata, **When** the user opens column controls, **Then** those detail fields are available without being forced into the default layout.
2. **Given** a table with long identifiers or technical strings, **When** the user inspects the list, **Then** those values remain readable and accessible without breaking the page layout.
### Edge Cases
- When a table has no records at all, it shows a domain-specific empty state with a clear explanation and only RBAC-allowed next steps.
- When a table contains relation-backed or computed fields that would create unacceptable sort or search cost, the standard allows a documented exception instead of forcing an expensive interaction.
- When a cross-tenant or cross-workspace list is shown, the default visible columns still preserve enough context to distinguish records safely.
- When exact chronology matters more than quick scanning, the table may keep an absolute time presentation as a documented exception to the general relative-time convention.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce Microsoft Graph calls, new write behavior, queue or schedule behavior, or `OperationRun` usage. It standardizes existing table UX on already-authorized screens only.
**Constitution alignment (RBAC-UX):** This feature touches both the tenant/admin plane and the platform plane, but it does not change authorization semantics. Non-membership remains deny-as-not-found, capability denial remains unchanged where it already applies, and all existing action-level server-side authorization must remain intact. Any new empty-state action shown on a surface must stay capability-gated and tenant-safe.
**Constitution alignment (BADGE-001):** Existing centralized badge semantics for status, outcome, severity, and boolean-like signals remain the source of truth. The standard may improve consistency of where badges appear in tables, but it must not introduce local badge vocabularies or ad-hoc status mappings.
**Constitution alignment (Filament Action Surfaces):** This feature satisfies the Action Surface Contract by preserving each tables existing action architecture as table-local behavior. The scope is limited to list consistency, empty states, pagination, persistence, and column visibility; row actions, bulk actions, header actions, and inspection affordances remain explicit per surface and are not globally redesigned.
**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature directly supports the table portions of UX-001 by requiring meaningful empty states and consistent search, sort, and filtering behavior for core dimensions. It does not change create, edit, or view form layout patterns.
### Functional Requirements
- **FR-001**: The system MUST define and document a repo-wide table standard for all production Filament list surfaces.
- **FR-002**: The standard MUST classify visible table columns into Primary, Context, and Detail tiers as a review and implementation convention.
- **FR-003**: Every production table MUST expose a searchable primary identifier unless a documented exception establishes that search provides no user value for that surface or would introduce unacceptable query cost.
- **FR-004**: Every production table MUST define an explicit default sort unless a documented exception is required for domain or query-safety reasons.
- **FR-005**: Meaningful identifiers, recency fields, statuses, and operational counts MUST be sortable when doing so is useful and safe for the underlying query.
- **FR-006**: Technical identifiers, low-frequency metadata, and secondary timestamps MUST not dominate the default list surface and SHOULD be available as on-demand detail where practical.
- **FR-007**: General-purpose tables SHOULD present no more than seven columns by default unless a denser default view is explicitly justified.
- **FR-008**: Timestamp, null-value, and identifier presentation MUST follow consistent product-wide rules so similar values scan the same way across tables.
- **FR-009**: Every production table MUST provide a domain-specific empty state with clear explanatory copy, and it MUST include a next step only when one is meaningful and authorized.
- **FR-010**: Pagination options and default page sizes MUST follow explicit conventions by table class rather than relying on inconsistent implicit defaults.
- **FR-011**: The designated critical resource lists (`TenantResource`, `PolicyResource`, `BackupSetResource`, `BackupScheduleResource`, `ProviderConnectionResource`, `FindingResource`, and `OperationRunResource`) MUST preserve search, sort, and filter state across refresh within the same session.
- **FR-012**: The standard MUST reduce default horizontal overload by moving lower-value detail out of the initial view before any cosmetic workaround is considered.
- **FR-013**: The rollout MUST not worsen known query-risk tables by forcing expensive sorts, searches, or row-level computations without review.
- **FR-014**: The feature MUST preserve existing RBAC behavior, tenancy boundaries, action semantics, and audit expectations for every affected table surface.
- **FR-015**: The rollout MUST be phased so that conventions and critical high-value tables are addressed before the remaining table surface is aligned.
- **FR-016**: Future table changes MUST be reviewable against the same standard so new list surfaces do not drift back to ad hoc behavior.
## UI Action Matrix *(mandatory when Filament is changed)*
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
RBAC gating (capability + enforcement helper), and whether the mutation writes an audit log.
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Resource list tables | app/Filament/Resources/**/Pages/List*.php | Existing table-local actions retained | Existing row inspection affordance retained per resource | Existing resource-local actions retained | Existing grouped bulk actions retained where already supported | Added or refined per table when meaningful and authorized | Unchanged by this spec | Unchanged by this spec | Unchanged | Standardizes list behavior only; no action redesign |
| Relation manager tables | app/Filament/Resources/**/RelationManagers/*.php | Existing relation-manager actions retained | Existing table-local inspection pattern retained | Existing relation-manager row actions retained | Existing grouped bulk actions retained where applicable | Added or refined per table when meaningful and authorized | Unchanged by this spec | Unchanged by this spec | Unchanged | Detail visibility and pagination are standardized without changing mutation semantics |
| Table widgets and custom table pages | app/Filament/Widgets/*.php and app/Filament/Pages/*.php | Existing page or widget actions retained | Existing inspection affordance retained where records are inspectable | Existing row actions retained | Existing grouped bulk actions retained where applicable | Added or refined per surface when meaningful and authorized | Unchanged by this spec | Not applicable unless the page already provides forms | Unchanged | Read-only or operational surfaces may have no row actions; this spec does not force them |
| Picker or selection tables | app/Livewire/** and table-backed selection pages | Existing selection or header actions retained | Existing selection affordance retained | Existing selection-related row actions retained | Existing grouped bulk actions retained where applicable | Added only when it helps a blocked user recover | Unchanged by this spec | Unchanged by this spec | Unchanged | This spec standardizes calm defaults and pagination, not picker workflow semantics |
### Key Entities *(include if feature involves data)*
- **Table Surface**: Any production Filament-backed list surface, including resource lists, relation managers, widgets, custom pages, and picker tables.
- **Column Visibility Tier**: The conceptual classification of a column as Primary, Context, or Detail, used to decide whether it should be dominant, visible by default, or available on demand.
- **Table Behavior Profile**: The combination of default sort, search scope, empty-state behavior, pagination, state persistence, and overflow handling expected for a given table.
- **Documented Exception**: A justified, reviewable deviation from the standard where domain clarity, tenant safety, or query cost makes the default rule inappropriate.
## Assumptions
- The existing audit inventory of approximately 36 production tables is sufficiently accurate to drive phased rollout planning, with minor recount differences allowed during implementation.
- Existing action labels, filters, query scopes, and badge mappings remain table-local unless a later spec proves a shared change is required.
- Critical rollout tables include `TenantResource`, `PolicyResource`, `BackupSetResource`, `BackupScheduleResource`, `ProviderConnectionResource`, `FindingResource`, `OperationRunResource`, and `BackupItemsRelationManager`; query-risk system pages such as `Directory/Workspaces` remain in scope for the broader rollout with documented exceptions where needed.
- Resource-list persistence is mandatory in this phase, while broader persistence beyond those surfaces may be adopted later only where it fits cleanly.
## Dependencies
- The feature depends on an up-to-date inventory of production table surfaces and their current behavior gaps.
- The feature depends on retaining current authorization policies, capability registries, and centralized badge semantics during rollout.
- The feature depends on performance review of relation-backed and computed columns before new sort or search behavior is enabled.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: In the post-rollout audit, 100% of production tables have an explicit default sort or a documented exception.
- **SC-002**: In the post-rollout audit, 100% of production tables provide a domain-specific empty state.
- **SC-003**: At least 90% of general-purpose production tables present seven or fewer columns by default unless a documented exception exists.
- **SC-004**: On all designated critical resource lists, a user can refresh the page without losing the current search, sort, and filter context during the same session.
- **SC-005**: In manual QA of critical tables, an operator can reach any hidden technical identifier or timestamp needed for troubleshooting in two interactions or fewer.

View File

@ -0,0 +1,224 @@
# Tasks: Filament Table UX Standardization & List Consistency
**Input**: Design documents from `/specs/125-table-ux-standardization/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md
**Tests**: Tests are REQUIRED for this runtime behavior change. Use Pest feature and guard coverage for critical list defaults, persisted table state, detail visibility, and query-risk exceptions.
**Operations**: No `OperationRun` work is required because this feature does not introduce long-running, remote, queued, or scheduled behavior.
**RBAC**: No new authorization behavior is introduced. Existing workspace and tenant isolation, capability checks, and destructive-action confirmation behavior must remain unchanged on all touched surfaces.
**Filament UI Action Surfaces**: This feature modifies Filament resources, relation managers, widgets, pages, and picker tables, but preserves their existing action architecture. Tasks must keep record inspection affordances, grouped bulk actions, and destructive confirmations intact.
**Filament UI UX-001**: This feature strengthens the table portions of UX-001 by standardizing empty states, search/sort behavior, and calm default visibility without changing create, edit, or view layouts.
**Badges**: Badge semantics must remain centralized through the existing `BadgeCatalog` and `BadgeRenderer` infrastructure; no ad-hoc status mappings are allowed.
**Organization**: Tasks are grouped by user story so each story remains independently testable.
## Phase 1: Setup (Shared Context)
**Purpose**: Align the rollout scope, target files, and existing test guardrails before touching runtime code.
- [X] T001 [P] Reconcile the rollout inventory, designated critical resource persistence set, and first-wave hotspot set in `specs/125-table-ux-standardization/spec.md`, `specs/125-table-ux-standardization/plan.md`, `specs/125-table-ux-standardization/research.md`, and `specs/125-table-ux-standardization/data-model.md` against the target table classes under `app/Filament/`, `app/Livewire/`, and `tests/Feature/`
- [X] T002 [P] Review existing list-surface guardrails in `tests/Feature/Guards/ActionSurfaceContractTest.php`, `tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, and the current list regressions under `tests/Feature/Filament/` before changing table defaults
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared standard, helper, and baseline test infrastructure that all user stories depend on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [X] T003 Create the internal table standard and future PR checklist in `docs/ui/filament-table-standard.md` using the approved Primary / Context / Detail, pagination, persistence, timestamp, null, and ID rules from `specs/125-table-ux-standardization/`
- [X] T004 [P] Introduce the narrow shared pagination profile helper in `app/Support/Filament/TablePaginationProfiles.php` for resource, relation manager, widget, picker, and custom-page page-size conventions without hiding per-surface search, sort, visibility, or empty-state behavior
- [X] T005 [P] Create baseline cross-surface regression coverage in `tests/Feature/Filament/TableStandardsBaselineTest.php` for explicit default sort, empty states, and calm default-column expectations on representative list surfaces
- [X] T006 [P] Create guard coverage in `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` to block missing default-sort, missing empty-state, and missing designated-resource persistence declarations on in-scope standardized tables, with explicit documented-exception handling for approved no-search or query-risk surfaces
**Checkpoint**: The standard, pagination profiles, and baseline guardrails are ready; user story work can now proceed.
---
## Phase 3: User Story 1 - Scan Core Lists Predictably (Priority: P1) 🎯 MVP
**Goal**: Make the highest-value and most overloaded lists calm, sortable, and consistently empty-state-aware by default.
**Independent Test**: Open each first-wave critical list, verify a searchable and sortable primary identifier, confirm calm default columns with detail available on demand, and verify domain-specific empty-state behavior on empty datasets.
### Tests for User Story 1 ⚠️
- [X] T007 [P] [US1] Create critical-list regression coverage in `tests/Feature/Filament/TableStandardsCriticalListsTest.php` for tenant, policy, backup set, backup schedule, provider connection, finding, operation run, and backup items relation-manager defaults
- [X] T008 [P] [US1] Extend existing list regressions in `tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`, `tests/Feature/Filament/PolicyListingTest.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, and `tests/Feature/Findings/FindingsListDefaultsTest.php` with sortability, empty-state, and visible-column assertions
### Implementation for User Story 1
- [X] T009 [P] [US1] Standardize primary-identifier search/sort, context columns, detail visibility, and empty-state behavior in `app/Filament/Resources/TenantResource.php`
- [X] T010 [P] [US1] Standardize primary-identifier search/sort, context columns, detail visibility, and empty-state behavior in `app/Filament/Resources/PolicyResource.php`
- [X] T011 [P] [US1] Standardize primary-identifier search/sort, context columns, detail visibility, and empty-state behavior in `app/Filament/Resources/BackupSetResource.php`
- [X] T012 [P] [US1] Standardize primary-identifier search/sort, context columns, detail visibility, and empty-state behavior in `app/Filament/Resources/BackupScheduleResource.php`
- [X] T013 [P] [US1] Standardize primary-identifier search/sort, context columns, detail visibility, and empty-state behavior in `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`
- [X] T014 [P] [US1] Standardize primary-identifier search/sort, context columns, detail visibility, and empty-state behavior in `app/Filament/Resources/FindingResource.php`
- [X] T015 [P] [US1] Standardize default sort, recency/status columns, detail visibility, and empty-state behavior in `app/Filament/Resources/OperationRunResource.php`
- [X] T016 [US1] Standardize the overloaded relation-backed list in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` without changing its existing row, bulk, and confirmation behavior
**Checkpoint**: The first-wave critical tables should now feel predictable and calmer by default while preserving existing actions and RBAC semantics.
---
## Phase 4: User Story 2 - Keep List Context Across Refresh (Priority: P2)
**Goal**: Persist search, sort, and filter state on key resource lists and align pagination behavior across table classes.
**Independent Test**: Apply search, sort, and filters on critical resource lists, refresh the page within the same session, and confirm the same list state remains active while approved page-size options stay consistent.
### Tests for User Story 2 ⚠️
- [X] T017 [P] [US2] Create resource-list persistence coverage in `tests/Feature/Filament/TableStatePersistenceTest.php` for search, sort, and filter state across refresh on `TenantResource`, `PolicyResource`, `BackupSetResource`, `BackupScheduleResource`, `ProviderConnectionResource`, `FindingResource`, and `OperationRunResource`
- [X] T018 [P] [US2] Extend scoped list coverage in `tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php`, `tests/Feature/Filament/PolicyListingTest.php`, `tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, and `tests/Feature/Findings/FindingsListFiltersTest.php` with resource pagination and persisted-state assertions
### Implementation for User Story 2
- [X] T019 [P] [US2] Apply native Filament search/sort/filter persistence and resource pagination profiles in `app/Filament/Resources/TenantResource.php`, `app/Filament/Resources/PolicyResource.php`, and `app/Filament/Resources/BackupSetResource.php`
- [X] T020 [P] [US2] Apply native Filament search/sort/filter persistence and resource pagination profiles in `app/Filament/Resources/BackupScheduleResource.php`, `app/Filament/Resources/ProviderConnectionResource.php`, `app/Filament/Resources/FindingResource.php`, and `app/Filament/Resources/OperationRunResource.php`
- [X] T021 [US2] Standardize pagination profiles across non-resource list surfaces in `app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php`, `app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php`, `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`, `app/Filament/Widgets/Dashboard/RecentOperations.php`, `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Directory/Tenants.php`, `app/Filament/System/Pages/Directory/Workspaces.php`, `app/Filament/System/Pages/Security/AccessLogs.php`, `app/Livewire/BackupSetPolicyPickerTable.php`, `app/Livewire/EntraGroupCachePickerTable.php`, and `app/Livewire/SettingsCatalogSettingsTable.php`
**Checkpoint**: Key resource lists should now preserve user context across refresh, and table-class pagination defaults should be consistent across the product.
---
## Phase 5: User Story 3 - Reveal Detail Only When Needed (Priority: P3)
**Goal**: Bring the remaining table surface into line on searchable primary identifiers, explicit default sort or documented exceptions, domain-specific empty states, hidden-by-default detail, timestamps, placeholders, identifiers, and query-safe exceptions.
**Independent Test**: Verify remaining resources, system pages, widgets, and picker tables expose a searchable primary identifier where appropriate, define an explicit default sort or documented exception, provide a domain-specific empty state, hide low-value technical detail by default, keep IDs readable without layout breakage, and document any approved no-search or query-risk exceptions.
### Tests for User Story 3 ⚠️
- [X] T022 [P] [US3] Create broad-rollout coverage in `tests/Feature/Filament/TableDetailVisibilityTest.php` for remaining resource, widget, page, and picker surfaces covering searchable primary identifiers where appropriate, explicit default sorts or documented exceptions, domain-specific empty states, detail visibility, timestamp rendering, placeholders, and identifier presentation
- [X] T023 [P] [US3] Add query-risk and documented-exception guard coverage in `tests/Feature/Guards/FilamentTableRiskExceptionsGuardTest.php` for computed and relation-backed columns that intentionally remain non-sortable or non-searchable, plus approved surfaces where search is explicitly documented as providing no user value
### Implementation for User Story 3
- [X] T024 [P] [US3] Standardize searchable primary identifiers where useful, explicit default sorts or documented exceptions, domain-specific empty states, calm defaults, toggle-hidden detail columns, timestamps, and null/ID rendering in `app/Filament/Resources/EntraGroupResource.php`, `app/Filament/Resources/AlertDeliveryResource.php`, `app/Filament/Resources/AlertRuleResource.php`, and `app/Filament/Resources/AlertDestinationResource.php`
- [X] T025 [P] [US3] Standardize searchable primary identifiers where useful, explicit default sorts or documented exceptions, domain-specific empty states, calm defaults, toggle-hidden detail columns, timestamps, and null/ID rendering in `app/Filament/Resources/BaselineProfileResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/InventoryItemResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, `app/Filament/Resources/ReviewPackResource.php`, and `app/Filament/Resources/Workspaces/WorkspaceResource.php`
- [X] T026 [P] [US3] Standardize remaining relation-manager surfaces for searchable primary identifiers where useful, explicit default sorts or documented exceptions, domain-specific empty states, and calm detail handling in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`, `app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php`, and `app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php`
- [X] T027 [P] [US3] Standardize custom pages, query-risk system pages, and widgets for searchable primary identifiers where useful, explicit default sorts or documented exceptions, domain-specific empty states, and calm detail handling in `app/Filament/Pages/InventoryCoverage.php`, `app/Filament/Pages/Monitoring/Operations.php`, `app/Filament/System/Pages/Directory/Tenants.php`, `app/Filament/System/Pages/Directory/Workspaces.php`, `app/Filament/System/Pages/Ops/Runs.php`, `app/Filament/System/Pages/Ops/Failures.php`, `app/Filament/System/Pages/Ops/Stuck.php`, `app/Filament/System/Pages/Security/AccessLogs.php`, `app/Filament/System/Pages/RepairWorkspaceOwners.php`, `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`, and `app/Filament/Widgets/Dashboard/RecentOperations.php`
- [X] T028 [US3] Standardize picker and selection tables for searchable primary identifiers where useful, explicit default sorts or documented exceptions, domain-specific empty states, and calm detail handling, then document surviving no-search and query-safe exceptions in `app/Livewire/BackupSetPolicyPickerTable.php`, `app/Livewire/EntraGroupCachePickerTable.php`, `app/Livewire/SettingsCatalogSettingsTable.php`, and `docs/ui/filament-table-standard.md`
**Checkpoint**: The broader table surface should now use the same default visibility, rendering, and documented-exception model as the first-wave critical lists.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Verify the full rollout, record a full-inventory compliance audit, format touched files, and complete manual QA before merge.
- [X] T029 [P] Run focused verification from `specs/125-table-ux-standardization/quickstart.md` with `vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStandardsBaselineTest.php tests/Feature/Filament/TableStandardsCriticalListsTest.php tests/Feature/Filament/TableStatePersistenceTest.php tests/Feature/Filament/TableDetailVisibilityTest.php tests/Feature/Filament/TenantResourceIndexIsWorkspaceScopedTest.php tests/Feature/Filament/PolicyListingTest.php tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php tests/Feature/Findings/FindingsListDefaultsTest.php tests/Feature/Findings/FindingsListFiltersTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Feature/Guards/FilamentTableRiskExceptionsGuardTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php`
- [X] T030 Run `vendor/bin/sail bin pint --dirty --format agent` after changing `app/Filament/`, `app/Livewire/`, `app/Support/Filament/`, `docs/ui/`, and `tests/Feature/`
- [X] T031 Produce the post-rollout audit in `docs/ui/filament-table-standard.md` or an adjacent rollout appendix by reconciling the full production table inventory against explicit default sort or documented exception, domain-specific empty states, default column-count targets, designated persistence surfaces, and approved no-search or query-risk exceptions
- [X] T032 [P] Complete manual QA from `specs/125-table-ux-standardization/quickstart.md` across first-wave and broad-rollout surfaces for persisted state, empty states, overflow handling, and documented no-search and query-risk exceptions before merge
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies; can start immediately.
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user stories.
- **User Story 1 (Phase 3)**: Depends on Foundational completion; this is the MVP slice.
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow User Story 1 because it touches the same critical resource files.
- **User Story 3 (Phase 5)**: Depends on Foundational completion and is safest after User Stories 1 and 2 establish the critical resource baseline.
- **Polish (Phase 6)**: Depends on completion of the desired user stories and closes with the final inventory audit.
### User Story Dependencies
- **User Story 1 (P1)**: No dependency on other stories; delivers the first high-value standardization slice.
- **User Story 2 (P2)**: Can be tested independently, but should land after US1 because it modifies the same critical resource tables to add persistence and shared pagination behavior.
- **User Story 3 (P3)**: Can be tested independently, but should land after the critical-list baseline is stable so broad-rollout regressions are easier to isolate.
### Within Each User Story
- Test tasks should be added first and observed failing before implementation is considered complete.
- Resource and relation-manager updates should preserve existing record inspection affordances, row actions, bulk actions, and destructive confirmations.
- Query-risk surfaces should document exceptions before adding new sorts or searches to computed or relation-backed columns.
- Shared pagination profiles should be reused where they reduce mechanical duplication, but domain-specific table logic must remain explicit in each `table()` definition.
### Parallel Opportunities
- `T001` and `T002` can run in parallel.
- `T004`, `T005`, and `T006` can run in parallel once the foundational direction is agreed.
- `T007` and `T008` can run in parallel because they touch different test files.
- `T009` through `T015` can run in parallel because they touch different resource files.
- `T017` and `T018` can run in parallel because they touch different test files.
- `T019` and `T020` can run in parallel because they split the critical resource set across different files.
- `T022` and `T023` can run in parallel because they touch different test files.
- `T024`, `T025`, `T026`, and `T027` can run in parallel because they address different groups of surfaces.
- `T029` and `T032` can run in parallel once implementation is complete.
---
## Parallel Example: User Story 1
```bash
# Split first-wave critical resources across separate files:
Task: "T009 [US1] Standardize TenantResource in app/Filament/Resources/TenantResource.php"
Task: "T010 [US1] Standardize PolicyResource in app/Filament/Resources/PolicyResource.php"
Task: "T011 [US1] Standardize BackupSetResource in app/Filament/Resources/BackupSetResource.php"
Task: "T012 [US1] Standardize BackupScheduleResource in app/Filament/Resources/BackupScheduleResource.php"
Task: "T013 [US1] Standardize ProviderConnectionResource in app/Filament/Resources/ProviderConnectionResource.php and app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php"
Task: "T014 [US1] Standardize FindingResource in app/Filament/Resources/FindingResource.php"
Task: "T015 [US1] Standardize OperationRunResource in app/Filament/Resources/OperationRunResource.php"
```
---
## Parallel Example: User Story 2
```bash
# Split persistence rollout across the critical resource files:
Task: "T019 [US2] Apply persistence and resource pagination profiles in app/Filament/Resources/TenantResource.php, app/Filament/Resources/PolicyResource.php, and app/Filament/Resources/BackupSetResource.php"
Task: "T020 [US2] Apply persistence and resource pagination profiles in app/Filament/Resources/BackupScheduleResource.php, app/Filament/Resources/ProviderConnectionResource.php, app/Filament/Resources/FindingResource.php, and app/Filament/Resources/OperationRunResource.php"
```
---
## Parallel Example: User Story 3
```bash
# Split the broad rollout by surface group:
Task: "T024 [US3] Standardize remaining alert and group resources"
Task: "T025 [US3] Standardize baseline, inventory, policy-version, review-pack, and workspace resources"
Task: "T026 [US3] Standardize remaining relation managers"
Task: "T027 [US3] Standardize custom pages, system pages, and widgets"
```
---
## 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 critical list surfaces independently.
5. Demo or review the first-wave standard before layering persistence and broad-rollout work.
### Incremental Delivery
1. Establish the standard, pagination profiles, and baseline guards.
2. Ship the first-wave critical lists in US1.
3. Add persisted list state and aligned pagination in US2.
4. Finish the remaining surface rollout and documented exceptions in US3.
5. Close with focused Sail tests, a full inventory audit, Pint, and manual QA.
### Suggested MVP Scope
- Deliver through **Phase 3 / User Story 1** for the first merge candidate.
- Defer persisted-state rollout and broad-surface cleanup until the critical list standards are accepted.
---
## Notes
- All tasks follow the required checklist format: checkbox, task ID, optional `[P]`, required story label for story phases, and exact file paths.
- No migrations, queue jobs, Graph integrations, or new authorization rules are part of this task list.
- The action-surface contract remains in force: no task should remove record inspection affordances, grouped bulk actions, or destructive confirmations from existing list surfaces.

View File

@ -2,11 +2,13 @@
declare(strict_types=1);
use App\Filament\Pages\TenantDashboard;
use App\Filament\Widgets\Dashboard\BaselineCompareNow;
use App\Models\BaselineProfile;
use App\Models\BaselineTenantAssignment;
use App\Models\OperationRun;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@ -37,9 +39,12 @@
'completed_at' => now()->subDay(),
]);
$this->actingAs($user)
->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk()
$this->actingAs($user);
Filament::setCurrentPanel(Filament::getPanel('tenant'));
Filament::setTenant($tenant, true);
Livewire::test(BaselineCompareNow::class)
->assertSee('Baseline Governance')
->assertSee('Baseline A')
->assertSee('No open drift — baseline compliant');

View File

@ -1,8 +1,11 @@
<?php
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use Filament\Facades\Filament;
use Livewire\Livewire;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -41,3 +44,35 @@
->assertSee('Policy A')
->assertDontSee('Policy B');
});
test('policy list keeps the standard table defaults and persists state in-session', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)
->test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync'])
->searchTable('Policy')
->call('sortTable', 'display_name', 'desc')
->set('tableFilters.visibility.value', 'active');
$table = $component->instance()->getTable();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('external_id')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
expect(session()->get($component->instance()->getTableSearchSessionKey()))->toBe('Policy');
expect(session()->get($component->instance()->getTableSortSessionKey()))->toBe('display_name:desc');
Livewire::actingAs($user)
->test(ListPolicies::class)
->assertSet('tableSearch', 'Policy')
->assertSet('tableSort', 'display_name:desc')
->assertSet('tableFilters.visibility.value', 'active');
});

View File

@ -3,8 +3,11 @@
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Models\ProviderConnection;
use Filament\Facades\Filament;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
it('renders Provider Connections DB-only (no outbound HTTP, no background work)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
@ -32,3 +35,38 @@
Bus::assertNothingDispatched();
});
it('keeps provider connection table defaults calm and persists state without outbound HTTP', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
assertNoOutboundHttp(function () use ($user): void {
$component = Livewire::actingAs($user)
->test(ListProviderConnections::class)
->assertTableEmptyStateActionsExistInOrder(['create'])
->searchTable('Contoso')
->call('sortTable', 'display_name', 'desc')
->set('tableFilters.default_only.isActive', true);
$table = $component->instance()->getTable();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
expect(session()->get($component->instance()->getTableSearchSessionKey()))->toBe('Contoso');
expect(session()->get($component->instance()->getTableSortSessionKey()))->toBe('display_name:desc');
Livewire::actingAs($user)
->test(ListProviderConnections::class)
->assertSet('tableSearch', 'Contoso')
->assertSet('tableSort', 'display_name:desc')
->assertSet('tableFilters.default_only.isActive', true);
});
});

View File

@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
use App\Filament\System\Pages\Directory\Workspaces;
use App\Filament\Widgets\Dashboard\RecentOperations;
use App\Livewire\EntraGroupCachePickerTable;
use App\Livewire\SettingsCatalogSettingsTable;
use App\Models\EntraGroup;
use App\Models\PlatformUser;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Facades\Filament;
use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function spec125DetailTable(Testable $component): Table
{
return $component->instance()->getTable();
}
/**
* @return array{0: \App\Models\User, 1: \App\Models\Tenant}
*/
function spec125DetailTenantContext(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
test()->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
return [$user, $tenant];
}
function spec125DetailPlatformContext(): PlatformUser
{
$platformUser = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
test()->actingAs($platformUser, 'platform');
return $platformUser;
}
it('keeps broad resource tables searchable on primary identifiers while hiding technical IDs by default', function (): void {
[$user, $tenant] = spec125DetailTenantContext();
$group = EntraGroup::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'entra_id' => '00000000-0000-0000-0000-1234567890ab',
'display_name' => 'Alpha Security Group',
'group_types' => null,
'security_enabled' => true,
'mail_enabled' => false,
'last_seen_at' => now(),
]);
$component = Livewire::actingAs($user)->test(ListEntraGroups::class)
->searchTable('Alpha')
->assertCanSeeTableRecords([$group]);
$table = spec125DetailTable($component);
expect($table->getDefaultSortColumn())->toBe('display_name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->getEmptyStateHeading())->toBe('No groups cached yet');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('entra_id')?->isToggleable())->toBeTrue();
expect($table->getColumn('entra_id')?->isToggledHiddenByDefault())->toBeTrue();
expect(array_keys($table->getVisibleColumns()))->not->toContain('entra_id');
});
it('keeps query-risk system pages explicit about what can and cannot be searched or sorted', function (): void {
spec125DetailPlatformContext();
Workspace::factory()->create([
'name' => 'Alpha Workspace',
]);
$component = Livewire::test(Workspaces::class);
$table = spec125DetailTable($component);
expect($table->getDefaultSortColumn())->toBe('name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::customPage());
expect($table->getEmptyStateHeading())->toBe('No workspaces found');
expect($table->getColumn('name')?->isSearchable())->toBeTrue();
expect($table->getColumn('health')?->isSearchable())->toBeFalse();
expect($table->getColumn('health')?->isSortable())->toBeFalse();
expect($table->getColumn('failed_runs_24h')?->isSearchable())->toBeFalse();
expect($table->getColumn('failed_runs_24h')?->isSortable())->toBeFalse();
});
it('keeps custom page tables explicit about pagination profiles and revealable detail', function (): void {
[$user] = spec125DetailTenantContext();
$component = Livewire::actingAs($user)->test(InventoryCoverage::class)
->assertTableEmptyStateActionsExistInOrder(['clear_filters'])
->searchTable('Scope Tag');
$table = spec125DetailTable($component);
expect($table->getDefaultSortColumn())->toBe('label');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::customPage());
expect($table->getColumn('type')?->isSortable())->toBeTrue();
expect($table->getColumn('label')?->isSortable())->toBeTrue();
expect($table->getColumn('category')?->isToggleable())->toBeTrue();
expect($table->getColumn('segment')?->isToggleable())->toBeTrue();
expect($table->getColumn('dependencies')?->isToggleable())->toBeTrue();
});
it('keeps dashboard widgets as glance surfaces instead of searchable investigative tables', function (): void {
[$user] = spec125DetailTenantContext();
$component = Livewire::actingAs($user)->test(RecentOperations::class);
$table = spec125DetailTable($component);
expect($table->getDefaultSortColumn())->toBe('created_at');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::widget());
expect($table->getEmptyStateHeading())->toBe('No operations yet');
expect($table->isSearchable())->toBeFalse();
expect($table->getColumn('type')?->isSortable())->toBeTrue();
expect($table->getColumn('status')?->isSortable())->toBeTrue();
expect($table->getColumn('outcome')?->isSortable())->toBeTrue();
expect($table->getColumn('created_at')?->isSortable())->toBeTrue();
expect($table->getColumn('status')?->isToggleable())->toBeTrue();
expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue();
expect(array_keys($table->getVisibleColumns()))->toBe([
'short_id',
'type',
'outcome',
'created_at',
]);
});
it('keeps picker tables workflow-local while preserving readable hidden troubleshooting detail', function (): void {
[$user, $tenant] = spec125DetailTenantContext();
$group = EntraGroup::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'entra_id' => '00000000-0000-0000-0000-1234567890ab',
'display_name' => 'Bravo Group',
'group_types' => null,
'security_enabled' => true,
'mail_enabled' => false,
'last_seen_at' => now(),
]);
Livewire::actingAs($user)
->test(EntraGroupCachePickerTable::class, [
'sourceGroupId' => 'source-group',
])
->assertCanSeeTableRecords([$group])
->assertTableColumnFormattedStateSet('entra_id', '…567890ab', $group);
$settingsComponent = Livewire::actingAs($user)->test(SettingsCatalogSettingsTable::class, [
'settingsRows' => [[
'__id' => 'setting-1',
'definition' => 'Device Lock',
'category' => 'Security',
'data_type' => 'Boolean',
'value' => 'Enabled',
'description' => 'Require a lock screen on managed devices.',
'path' => './deviceLock',
]],
'context' => 'policy',
]);
$settingsTable = spec125DetailTable($settingsComponent);
expect($settingsTable->getDefaultSortColumn())->toBe('definition');
expect($settingsTable->getDefaultSortDirection())->toBe('asc');
expect($settingsTable->getPaginationPageOptions())->toBe(TablePaginationProfiles::picker());
expect($settingsTable->getColumn('definition')?->isSearchable())->toBeTrue();
expect($settingsTable->getColumn('definition')?->isSortable())->toBeTrue();
expect($settingsTable->getColumn('description')?->isToggleable())->toBeTrue();
expect($settingsTable->getColumn('description')?->isToggledHiddenByDefault())->toBeTrue();
expect($settingsTable->getColumn('path')?->isToggleable())->toBeTrue();
expect($settingsTable->getColumn('path')?->isToggledHiddenByDefault())->toBeTrue();
});

View File

@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\InventoryCoverage;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\Pages\ViewPolicy;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
use App\Livewire\EntraGroupCachePickerTable;
use App\Models\Policy;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Facades\Filament;
use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function spec125BaselineTable(Testable $component): Table
{
return $component->instance()->getTable();
}
/**
* @return array{0: \App\Models\User, 1: \App\Models\Tenant}
*/
function spec125BaselineTenantContext(): array
{
[$user, $tenant] = createUserWithTenant(role: 'owner');
test()->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
return [$user, $tenant];
}
it('keeps the policy resource list as the baseline resource-standard example', function (): void {
[$user] = spec125BaselineTenantContext();
$component = Livewire::actingAs($user)->test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync']);
$table = spec125BaselineTable($component);
expect($table->getDefaultSortColumn())->toBe('display_name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No policies synced yet');
expect($table->getEmptyStateDescription())->toBe('Sync your first tenant to see Intune policies here.');
expect(array_keys($table->getVisibleColumns()))->toContain('display_name', 'policy_type', 'platform', 'last_synced_at');
$displayName = $table->getColumn('display_name');
$externalId = $table->getColumn('external_id');
expect($displayName)->not->toBeNull();
expect($displayName?->isSearchable())->toBeTrue();
expect($displayName?->isSortable())->toBeTrue();
expect($externalId)->not->toBeNull();
expect($externalId?->isToggleable())->toBeTrue();
expect($externalId?->isToggledHiddenByDefault())->toBeTrue();
expect(array_keys($table->getVisibleColumns()))->not->toContain('external_id');
});
it('keeps the policy versions relation manager on the standard relation-manager contract', function (): void {
[$user, $tenant] = spec125BaselineTenantContext();
$policy = Policy::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$component = Livewire::actingAs($user)->test(VersionsRelationManager::class, [
'ownerRecord' => $policy,
'pageClass' => ViewPolicy::class,
]);
$table = spec125BaselineTable($component);
expect($table->getDefaultSortColumn())->toBe('version_number');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::relationManager());
expect($table->getEmptyStateHeading())->toBe('No versions captured');
expect($table->getEmptyStateDescription())->toBe('Capture or sync this policy again to create version history entries.');
expect($table->getColumn('version_number')?->isSortable())->toBeTrue();
expect($table->getColumn('captured_at')?->isSortable())->toBeTrue();
expect($table->getColumn('policy_type')?->isToggleable())->toBeTrue();
expect($table->getColumn('policy_type')?->isToggledHiddenByDefault())->toBeTrue();
});
it('keeps the dashboard widget profile minimal and scan-first', function (): void {
[$user] = spec125BaselineTenantContext();
$component = Livewire::actingAs($user)->test(RecentDriftFindings::class);
$table = spec125BaselineTable($component);
expect($table->getDefaultSortColumn())->toBe('created_at');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::widget());
expect($table->getEmptyStateHeading())->toBe('No drift findings');
expect($table->getEmptyStateDescription())->toBe('You\'re looking good — no drift findings to review yet.');
expect(array_keys($table->getVisibleColumns()))->toBe([
'short_id',
'subject_display_name',
'severity',
'created_at',
]);
expect($table->getColumn('status')?->isToggleable())->toBeTrue();
expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('severity')?->isSortable())->toBeTrue();
expect($table->getColumn('created_at')?->isSortable())->toBeTrue();
});
it('keeps custom table pages on their explicit profile without hidden framework defaults', function (): void {
[$user] = spec125BaselineTenantContext();
$component = Livewire::actingAs($user)->test(InventoryCoverage::class)
->assertTableEmptyStateActionsExistInOrder(['clear_filters']);
$table = spec125BaselineTable($component);
expect($table->getDefaultSortColumn())->toBe('label');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::customPage());
expect($table->getEmptyStateHeading())->toBe('No coverage entries match this view');
expect($table->getEmptyStateDescription())->toBe('Clear the current search or filters to return to the full coverage matrix.');
expect($table->getColumn('type')?->isSortable())->toBeTrue();
expect($table->getColumn('label')?->isSortable())->toBeTrue();
expect($table->getColumn('dependencies')?->isToggleable())->toBeTrue();
});
it('keeps picker tables workflow-local while still following the shared picker defaults', function (): void {
[$user] = spec125BaselineTenantContext();
$component = Livewire::actingAs($user)->test(EntraGroupCachePickerTable::class, [
'sourceGroupId' => 'source-group-1',
]);
$table = spec125BaselineTable($component);
expect($table->getDefaultSortColumn())->toBe('display_name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::picker());
expect($table->getEmptyStateHeading())->toBe('No cached groups found');
expect($table->getEmptyStateDescription())->toBe('Run “Sync Groups” first, then come back here.');
expect($table->persistsSearchInSession())->toBeFalse();
expect($table->persistsSortInSession())->toBeFalse();
expect($table->persistsFiltersInSession())->toBeFalse();
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('entra_id')?->isToggleable())->toBeTrue();
expect($table->getColumn('entra_id')?->isToggledHiddenByDefault())->toBeTrue();
});

View File

@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource\Pages\EditBackupSet;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\BackupSet;
use App\Support\Filament\TablePaginationProfiles;
use Filament\Facades\Filament;
use Filament\Tables\Table;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function spec125CriticalTable(Testable $component): Table
{
return $component->instance()->getTable();
}
/**
* @return array{0: \App\Models\User, 1: \App\Models\Tenant}
*/
function spec125CriticalTenantContext(bool $ensureDefaultMicrosoftProviderConnection = true): array
{
[$user, $tenant] = createUserWithTenant(
role: 'owner',
ensureDefaultMicrosoftProviderConnection: $ensureDefaultMicrosoftProviderConnection,
);
test()->actingAs($user);
return [$user, $tenant];
}
it('standardizes the tenant list defaults around searchable identity and hidden detail', function (): void {
[$user] = spec125CriticalTenantContext();
Filament::setTenant(null, true);
$component = Livewire::actingAs($user)->test(ListTenants::class)
->assertTableEmptyStateActionsExistInOrder(['add_tenant']);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No tenants connected');
expect($table->getEmptyStateDescription())->toBe('Add a tenant to start syncing inventory, policies, and provider health into this workspace.');
expect($table->getColumn('name')?->isSearchable())->toBeTrue();
expect($table->getColumn('name')?->isSortable())->toBeTrue();
expect($table->getColumn('tenant_id')?->isToggleable())->toBeTrue();
expect($table->getColumn('tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('domain')?->isToggleable())->toBeTrue();
expect($table->getColumn('domain')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('created_at')?->isToggleable())->toBeTrue();
expect($table->getColumn('created_at')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});
it('standardizes the policy list defaults around calm scanning and persistence', function (): void {
[$user, $tenant] = spec125CriticalTenantContext();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync']);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('display_name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('external_id')?->isToggleable())->toBeTrue();
expect($table->getColumn('external_id')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});
it('standardizes the backup-set list around recency and toggle-hidden operational detail', function (): void {
[$user, $tenant] = spec125CriticalTenantContext();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(ListBackupSets::class)
->assertTableEmptyStateActionsExistInOrder(['create']);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('created_at');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No backup sets');
expect($table->getColumn('name')?->isSearchable())->toBeTrue();
expect($table->getColumn('name')?->isSortable())->toBeTrue();
expect($table->getColumn('item_count')?->isSortable())->toBeTrue();
expect($table->getColumn('created_by')?->isToggleable())->toBeTrue();
expect($table->getColumn('created_by')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('created_at')?->isToggleable())->toBeTrue();
expect($table->getColumn('created_at')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});
it('standardizes the backup-schedule list around next-run ordering and hidden secondary detail', function (): void {
[$user, $tenant] = spec125CriticalTenantContext();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(ListBackupSchedules::class)
->assertTableEmptyStateActionsExistInOrder(['create']);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('next_run_at');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No schedules configured');
expect($table->getColumn('name')?->isSearchable())->toBeTrue();
expect($table->getColumn('name')?->isSortable())->toBeTrue();
expect($table->getColumn('timezone')?->isToggleable())->toBeTrue();
expect($table->getColumn('timezone')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('retention_keep_last')?->isToggleable())->toBeTrue();
expect($table->getColumn('retention_keep_last')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('last_run_status')?->isToggleable())->toBeTrue();
expect($table->getColumn('last_run_status')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});
it('standardizes the provider-connections list around searchable names and tenant-safe empty states', function (): void {
[$user, $tenant] = spec125CriticalTenantContext(ensureDefaultMicrosoftProviderConnection: false);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(ListProviderConnections::class)
->assertTableEmptyStateActionsExistInOrder(['create']);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('display_name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No provider connections found');
expect($table->getEmptyStateDescription())->toBe('Create a Microsoft provider connection or adjust the tenant filter to inspect another managed tenant.');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('provider')?->isToggleable())->toBeTrue();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('entra_tenant_id')?->isToggleable())->toBeTrue();
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('last_error_reason_code')?->isToggleable())->toBeTrue();
expect($table->getColumn('last_error_reason_code')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('last_error_message')?->isToggleable())->toBeTrue();
expect($table->getColumn('last_error_message')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});
it('standardizes the findings list around open triage work with hidden forensic detail', function (): void {
[$user, $tenant] = spec125CriticalTenantContext();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(ListFindings::class);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('created_at');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No findings match this view');
expect($table->getColumn('subject_display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('due_at')?->isSortable())->toBeTrue();
expect($table->getColumn('evidence_fidelity')?->isToggleable())->toBeTrue();
expect($table->getColumn('evidence_fidelity')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('subject_type')?->isToggleable())->toBeTrue();
expect($table->getColumn('subject_type')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('subject_external_id')?->isToggleable())->toBeTrue();
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('scope_key')?->isToggleable())->toBeTrue();
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});
it('standardizes the monitoring operations view through the operation-run resource table contract', function (): void {
[$user, $tenant] = spec125CriticalTenantContext();
Filament::setTenant($tenant, true);
$component = Livewire::actingAs($user)->test(Operations::class);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('created_at');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::resource());
expect($table->persistsSearchInSession())->toBeTrue();
expect($table->persistsSortInSession())->toBeTrue();
expect($table->persistsFiltersInSession())->toBeTrue();
expect($table->getEmptyStateHeading())->toBe('No operation runs found');
expect($table->getEmptyStateDescription())->toBe('Queued, running, and completed operations will appear here when work is triggered in this scope.');
expect($table->getEmptyStateActions())->toBeEmpty();
expect($table->getColumn('type')?->isSearchable())->toBeTrue();
expect($table->getColumn('type')?->isSortable())->toBeTrue();
expect($table->getColumn('created_at')?->isSortable())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});
it('standardizes the backup-items relation manager without disturbing its action surface', function (): void {
[$user, $tenant] = spec125CriticalTenantContext();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$backupSet = BackupSet::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
]);
$component = Livewire::actingAs($user)->test(BackupItemsRelationManager::class, [
'ownerRecord' => $backupSet,
'pageClass' => EditBackupSet::class,
])
->assertTableEmptyStateActionsExistInOrder(['addPoliciesEmpty']);
$table = spec125CriticalTable($component);
expect($table->getDefaultSortColumn())->toBe('policy.display_name');
expect($table->getDefaultSortDirection())->toBe('asc');
expect($table->getPaginationPageOptions())->toBe(TablePaginationProfiles::relationManager());
expect($table->getEmptyStateHeading())->toBe('No policies in this backup set');
expect($table->getEmptyStateDescription())->toBe('Add policies to capture versions and assignments inside this backup set.');
expect($table->getColumn('policy.display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('policy.display_name')?->isSortable())->toBeTrue();
expect($table->getColumn('policy_identifier')?->isToggleable())->toBeTrue();
expect($table->getColumn('policy_identifier')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('scope_tags')?->isToggleable())->toBeTrue();
expect($table->getColumn('scope_tags')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('captured_at')?->isToggleable())->toBeTrue();
expect($table->getColumn('captured_at')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('created_at')?->isToggleable())->toBeTrue();
expect($table->getColumn('created_at')?->isToggledHiddenByDefault())->toBeTrue();
});

View File

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Monitoring\Operations;
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\FindingResource\Pages\ListFindings;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* @param array<string, mixed> $parameters
*/
function spec125AssertPersistedTableState(
string $componentClass,
array $parameters,
string $search,
string $sortColumn,
string $sortDirection,
string $filterPath,
mixed $filterValue,
): void {
$component = Livewire::test($componentClass, $parameters)
->searchTable($search)
->call('sortTable', $sortColumn, $sortDirection)
->set($filterPath, $filterValue);
$instance = $component->instance();
expect(session()->get($instance->getTableSearchSessionKey()))->toBe($search);
expect(session()->get($instance->getTableSortSessionKey()))->toBe("{$sortColumn}:{$sortDirection}");
expect(data_get(session()->get($instance->getTableFiltersSessionKey()), str($filterPath)->after('tableFilters.')->value()))->toBe($filterValue);
Livewire::test($componentClass, $parameters)
->assertSet('tableSearch', $search)
->assertSet('tableSort', "{$sortColumn}:{$sortDirection}")
->assertSet($filterPath, $filterValue);
}
it('persists tenant list search, sort, and filter state across remounts', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
spec125AssertPersistedTableState(
ListTenants::class,
[],
'Tenant',
'name',
'desc',
'tableFilters.environment.value',
'prod',
);
});
it('persists policy list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListPolicies::class,
[],
'Policy',
'display_name',
'desc',
'tableFilters.visibility.value',
'active',
);
});
it('persists backup-set list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListBackupSets::class,
[],
'Backup',
'name',
'desc',
'tableFilters.trashed.value',
1,
);
});
it('persists backup-schedule list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListBackupSchedules::class,
[],
'Schedule',
'name',
'desc',
'tableFilters.enabled_state.value',
'enabled',
);
});
it('persists provider-connections list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListProviderConnections::class,
[],
'Contoso',
'display_name',
'desc',
'tableFilters.default_only.isActive',
true,
);
});
it('persists findings list search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
ListFindings::class,
[],
'drift',
'created_at',
'asc',
'tableFilters.status.value',
'new',
);
});
it('persists monitoring operations search, sort, and filter state across remounts', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant($tenant, true);
spec125AssertPersistedTableState(
Operations::class,
[],
'policy',
'type',
'desc',
'tableFilters.status.value',
'queued',
);
});

View File

@ -33,9 +33,9 @@
$this->get(TenantDashboard::getUrl(tenant: $tenant))
->assertOk()
->assertSee('/admin/workspaces', false)
->assertSee('Needs Attention')
->assertSee('Recent Operations')
->assertSee('Recent Drift Findings');
->assertSee('Needs Attention');
// RecentOperations and RecentDriftFindings are lazy-loaded widgets
// and will not appear in the initial server-rendered HTML.
});
Bus::assertNothingDispatched();

View File

@ -8,7 +8,9 @@
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@ -71,3 +73,35 @@
->assertSee('Tenant A')
->assertDontSee('Tenant B');
});
it('keeps tenant list defaults calm and persists list state in-session', function (): void {
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Filament::setTenant(null, true);
$component = Livewire::actingAs($user)
->test(\App\Filament\Resources\TenantResource\Pages\ListTenants::class)
->assertTableEmptyStateActionsExistInOrder(['add_tenant'])
->searchTable('Tenant')
->call('sortTable', 'name', 'desc')
->set('tableFilters.environment.value', 'prod');
$table = $component->instance()->getTable();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect($table->getEmptyStateHeading())->toBe('No tenants connected');
expect($table->getColumn('name')?->isSearchable())->toBeTrue();
expect($table->getColumn('name')?->isSortable())->toBeTrue();
expect($table->getColumn('tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('domain')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
expect(session()->get($component->instance()->getTableSearchSessionKey()))->toBe('Tenant');
expect(session()->get($component->instance()->getTableSortSessionKey()))->toBe('name:desc');
Livewire::actingAs($user)
->test(\App\Filament\Resources\TenantResource\Pages\ListTenants::class)
->assertSet('tableSearch', 'Tenant')
->assertSet('tableSort', 'name:desc')
->assertSet('tableFilters.environment.value', 'prod');
});

View File

@ -49,3 +49,24 @@
->assertCanSeeTableRecords([$openDrift, $openPermission, $openEntra, $reopened])
->assertCanNotSeeTableRecords([$resolved, $closed, $riskAccepted]);
});
it('keeps findings list defaults calm with explicit sortability and hidden forensic detail', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::test(ListFindings::class);
$table = $component->instance()->getTable();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect($table->getDefaultSortColumn())->toBe('created_at');
expect($table->getDefaultSortDirection())->toBe('desc');
expect($table->getEmptyStateHeading())->toBe('No findings match this view');
expect($table->getColumn('subject_display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('due_at')?->isSortable())->toBeTrue();
expect($table->getColumn('evidence_fidelity')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('subject_type')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('subject_external_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue();
expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7);
});

View File

@ -91,3 +91,25 @@
->assertCanSeeTableRecords([$assignedToMe])
->assertCanNotSeeTableRecords([$assignedToOther, $unassigned]);
});
it('persists findings search, sort, and filter state while keeping the resource pagination profile', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$this->actingAs($user);
Filament::setTenant($tenant, true);
$component = Livewire::test(ListFindings::class)
->searchTable('drift')
->call('sortTable', 'created_at', 'asc')
->set('tableFilters.status.value', Finding::STATUS_NEW);
$table = $component->instance()->getTable();
expect($table->getPaginationPageOptions())->toBe(\App\Support\Filament\TablePaginationProfiles::resource());
expect(session()->get($component->instance()->getTableSearchSessionKey()))->toBe('drift');
expect(session()->get($component->instance()->getTableSortSessionKey()))->toBe('created_at:asc');
Livewire::test(ListFindings::class)
->assertSet('tableSearch', 'drift')
->assertSet('tableSort', 'created_at:asc')
->assertSet('tableFilters.status.value', Finding::STATUS_NEW);
});

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
it('documents the approved no-search and query-risk exceptions in the table standard', function (): void {
$contents = file_get_contents(base_path('docs/ui/filament-table-standard.md'));
expect($contents)->toBeString();
expect($contents)->toContain('RecentDriftFindings');
expect($contents)->toContain('RecentOperations');
expect($contents)->toContain('Directory/Workspaces');
expect($contents)->toContain('InventoryCoverage');
expect($contents)->toContain('Picker tables keep workflow-local search only');
});
it('keeps computed workspace health metrics out of sortable and searchable columns', function (): void {
$contents = file_get_contents(base_path('app/Filament/System/Pages/Directory/Workspaces.php'));
expect($contents)->toBeString();
$healthBlockMatched = preg_match("/TextColumn::make\\('health'\\)(.*?)(?:TextColumn::make\\('failed_runs_24h'\\)|->recordUrl)/s", $contents, $healthMatches);
$failedRunsBlockMatched = preg_match("/TextColumn::make\\('failed_runs_24h'\\)(.*?)(?:->recordUrl|->emptyStateHeading)/s", $contents, $failedRunsMatches);
expect($healthBlockMatched)->toBe(1);
expect($failedRunsBlockMatched)->toBe(1);
expect($healthMatches[1] ?? '')->not->toContain('->searchable(');
expect($healthMatches[1] ?? '')->not->toContain('->sortable(');
expect($failedRunsMatches[1] ?? '')->not->toContain('->searchable(');
expect($failedRunsMatches[1] ?? '')->not->toContain('->sortable(');
});
it('keeps dashboard widgets as glance surfaces instead of searchable workbenches', function (): void {
foreach ([
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php',
] as $relativePath) {
$contents = file_get_contents(base_path($relativePath));
expect($contents)->toBeString();
expect($contents)->not->toContain('->searchable(');
}
});

View File

@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Collection;
it('declares default sorts for the standardized table surface inventory', function (): void {
$paths = collect([
'app/Filament/Resources/TenantResource.php',
'app/Filament/Resources/PolicyResource.php',
'app/Filament/Resources/BackupSetResource.php',
'app/Filament/Resources/BackupScheduleResource.php',
'app/Filament/Resources/ProviderConnectionResource.php',
'app/Filament/Resources/FindingResource.php',
'app/Filament/Resources/OperationRunResource.php',
'app/Filament/Resources/EntraGroupResource.php',
'app/Filament/Resources/AlertDeliveryResource.php',
'app/Filament/Resources/AlertRuleResource.php',
'app/Filament/Resources/AlertDestinationResource.php',
'app/Filament/Resources/BaselineProfileResource.php',
'app/Filament/Resources/BaselineSnapshotResource.php',
'app/Filament/Resources/InventoryItemResource.php',
'app/Filament/Resources/PolicyVersionResource.php',
'app/Filament/Resources/ReviewPackResource.php',
'app/Filament/Resources/Workspaces/WorkspaceResource.php',
'app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php',
'app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php',
'app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php',
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php',
'app/Filament/System/Pages/Ops/Failures.php',
'app/Filament/System/Pages/Ops/Stuck.php',
'app/Filament/System/Pages/Security/AccessLogs.php',
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php',
]);
/** @var Collection<int, string> $missing */
$missing = $paths
->filter(function (string $relativePath): bool {
$contents = file_get_contents(base_path($relativePath));
return ! is_string($contents) || ! str_contains($contents, '->defaultSort(');
})
->values();
expect($missing)->toBeEmpty('Missing explicit default sort declarations: '.implode(', ', $missing->all()));
});
it('declares domain-specific empty states across the standardized table surface inventory', function (): void {
$patternByPath = [
'app/Filament/Resources/TenantResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/PolicyResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BackupSetResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BackupScheduleResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php' => ['getTableEmptyStateHeading', 'getTableEmptyStateDescription'],
'app/Filament/Resources/FindingResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/OperationRunResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/EntraGroupResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/AlertDeliveryResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/AlertRuleResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/AlertDestinationResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BaselineProfileResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BaselineSnapshotResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/InventoryItemResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/PolicyVersionResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/ReviewPackResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/Workspaces/WorkspaceResource.php' => ['->emptyStateHeading('],
'app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Pages/InventoryCoverage.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Ops/Failures.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Ops/Stuck.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Security/AccessLogs.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('],
'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('],
];
$missing = [];
foreach ($patternByPath as $relativePath => $patterns) {
$contents = file_get_contents(base_path($relativePath));
if (! is_string($contents)) {
$missing[] = $relativePath;
continue;
}
foreach ($patterns as $pattern) {
if (! str_contains($contents, $pattern)) {
$missing[] = $relativePath;
break;
}
}
}
expect($missing)->toBeEmpty('Missing standardized empty-state declarations: '.implode(', ', $missing));
});
it('keeps persistence declarations explicit on the designated critical resource lists', function (): void {
$paths = [
'app/Filament/Resources/TenantResource.php',
'app/Filament/Resources/PolicyResource.php',
'app/Filament/Resources/BackupSetResource.php',
'app/Filament/Resources/BackupScheduleResource.php',
'app/Filament/Resources/ProviderConnectionResource.php',
'app/Filament/Resources/FindingResource.php',
'app/Filament/Resources/OperationRunResource.php',
];
$missing = [];
foreach ($paths as $relativePath) {
$contents = file_get_contents(base_path($relativePath));
if (! is_string($contents)) {
$missing[] = $relativePath;
continue;
}
foreach (['->persistSearchInSession()', '->persistSortInSession()', '->persistFiltersInSession()'] as $pattern) {
if (! str_contains($contents, $pattern)) {
$missing[] = "{$relativePath} ({$pattern})";
}
}
}
expect($missing)->toBeEmpty('Missing persistence declarations: '.implode(', ', $missing));
});
it('uses the shared pagination profile helper on standardized surfaces', function (): void {
$paths = [
'app/Filament/Resources/TenantResource.php',
'app/Filament/Resources/PolicyResource.php',
'app/Filament/Resources/BackupSetResource.php',
'app/Filament/Resources/BackupScheduleResource.php',
'app/Filament/Resources/ProviderConnectionResource.php',
'app/Filament/Resources/FindingResource.php',
'app/Filament/Resources/OperationRunResource.php',
'app/Filament/Resources/EntraGroupResource.php',
'app/Filament/Resources/AlertDeliveryResource.php',
'app/Filament/Resources/AlertRuleResource.php',
'app/Filament/Resources/AlertDestinationResource.php',
'app/Filament/Resources/BaselineProfileResource.php',
'app/Filament/Resources/BaselineSnapshotResource.php',
'app/Filament/Resources/InventoryItemResource.php',
'app/Filament/Resources/PolicyVersionResource.php',
'app/Filament/Resources/ReviewPackResource.php',
'app/Filament/Resources/Workspaces/WorkspaceResource.php',
'app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php',
'app/Filament/Resources/Workspaces/RelationManagers/WorkspaceMembershipsRelationManager.php',
'app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php',
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php',
'app/Filament/System/Pages/Ops/Failures.php',
'app/Filament/System/Pages/Ops/Stuck.php',
'app/Filament/System/Pages/Security/AccessLogs.php',
'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php',
];
$missing = collect($paths)
->filter(function (string $relativePath): bool {
$contents = file_get_contents(base_path($relativePath));
return ! is_string($contents) || ! str_contains($contents, 'TablePaginationProfiles::');
})
->values()
->all();
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing));
});