From a79405cdb2fc506b7fdbc93aa78812d3d0a102ce Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 9 Mar 2026 07:25:40 +0100 Subject: [PATCH] feat: standardize filter ux across key resources --- .github/agents/copilot-instructions.md | 4 +- .../Resources/AlertDeliveryResource.php | 21 +- .../Resources/BaselineProfileResource.php | 8 + ...selineTenantAssignmentsRelationManager.php | 104 ++++++++- app/Filament/Resources/EntraGroupResource.php | 7 +- app/Filament/Resources/FindingResource.php | 14 +- .../Resources/InventoryItemResource.php | 37 +++- .../Resources/OperationRunResource.php | 36 +-- .../Resources/PolicyVersionResource.php | 19 +- app/Filament/Resources/RestoreRunResource.php | 68 +++++- app/Support/Filament/FilterOptionCatalog.php | 205 ++++++++++++++++++ app/Support/Filament/FilterPresets.php | 86 ++++++++ .../checklists/requirements.md | 35 +++ .../filament-filter-state.openapi.yaml | 180 +++++++++++++++ .../data-model.md | 132 +++++++++++ specs/126-filter-ux-standardization/plan.md | 184 ++++++++++++++++ .../quickstart.md | 50 +++++ .../126-filter-ux-standardization/research.md | 74 +++++++ specs/126-filter-ux-standardization/spec.md | 182 ++++++++++++++++ specs/126-filter-ux-standardization/tasks.md | 196 +++++++++++++++++ .../AlertDeliveryDeepLinkFiltersTest.php | 108 +++++++++ .../Alerts/AlertDeliveryViewerTest.php | 51 +++++ .../BaselineProfileListFiltersTest.php | 101 +++++++++ ...neTenantAssignmentsRelationManagerTest.php | 69 ++++++ .../Filament/InventoryItemListFiltersTest.php | 83 +++++++ .../Filament/OperationRunListFiltersTest.php | 124 +++++++++++ .../Filament/PolicyVersionListFiltersTest.php | 176 +++++++++++++++ .../Filament/RestoreRunListFiltersTest.php | 153 +++++++++++++ .../Filament/TableStatePersistenceTest.php | 94 ++++++++ .../Findings/FindingsListDefaultsTest.php | 22 ++ .../Findings/FindingsListFiltersTest.php | 75 +++++++ .../FilamentTableStandardsGuardTest.php | 67 ++++++ .../TenantFilterOverrideTest.php | 45 ++++ ...InventoryItemResourceAuthorizationTest.php | 51 +++++ 34 files changed, 2780 insertions(+), 81 deletions(-) create mode 100644 app/Support/Filament/FilterOptionCatalog.php create mode 100644 app/Support/Filament/FilterPresets.php create mode 100644 specs/126-filter-ux-standardization/checklists/requirements.md create mode 100644 specs/126-filter-ux-standardization/contracts/filament-filter-state.openapi.yaml create mode 100644 specs/126-filter-ux-standardization/data-model.md create mode 100644 specs/126-filter-ux-standardization/plan.md create mode 100644 specs/126-filter-ux-standardization/quickstart.md create mode 100644 specs/126-filter-ux-standardization/research.md create mode 100644 specs/126-filter-ux-standardization/spec.md create mode 100644 specs/126-filter-ux-standardization/tasks.md create mode 100644 tests/Feature/Filament/BaselineProfileListFiltersTest.php create mode 100644 tests/Feature/Filament/BaselineTenantAssignmentsRelationManagerTest.php create mode 100644 tests/Feature/Filament/InventoryItemListFiltersTest.php create mode 100644 tests/Feature/Filament/OperationRunListFiltersTest.php create mode 100644 tests/Feature/Filament/PolicyVersionListFiltersTest.php create mode 100644 tests/Feature/Filament/RestoreRunListFiltersTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 5410874..a2fa18f 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -52,6 +52,8 @@ ## Active Technologies - 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 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables (126-filter-ux-standardization) +- PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant contex (126-filter-ux-standardization) - PHP 8.4.15 (feat/005-bulk-operations) @@ -71,8 +73,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 126-filter-ux-standardization: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables - 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] diff --git a/app/Filament/Resources/AlertDeliveryResource.php b/app/Filament/Resources/AlertDeliveryResource.php index a6e1d48..28c6062 100644 --- a/app/Filament/Resources/AlertDeliveryResource.php +++ b/app/Filament/Resources/AlertDeliveryResource.php @@ -12,6 +12,8 @@ use App\Models\User; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Filament\FilterOptionCatalog; +use App\Support\Filament\FilterPresets; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -214,13 +216,17 @@ public static function table(Table $table): Table return $table ->defaultSort('id', 'desc') ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() ->recordUrl(fn (AlertDelivery $record): ?string => static::canView($record) ? static::getUrl('view', ['record' => $record]) : null) ->columns([ TextColumn::make('created_at') ->label('Created') - ->since(), + ->since() + ->sortable(), TextColumn::make('tenant.name') ->label('Tenant') ->searchable(), @@ -235,7 +241,8 @@ public static function table(Table $table): Table ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus)) ->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)), + ->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)) + ->sortable(), TextColumn::make('rule.name') ->label('Rule') ->placeholder('—'), @@ -248,14 +255,7 @@ public static function table(Table $table): Table ]) ->filters([ SelectFilter::make('status') - ->options([ - AlertDelivery::STATUS_QUEUED => 'Queued', - AlertDelivery::STATUS_DEFERRED => 'Deferred', - AlertDelivery::STATUS_SENT => 'Sent', - AlertDelivery::STATUS_FAILED => 'Failed', - AlertDelivery::STATUS_SUPPRESSED => 'Suppressed', - AlertDelivery::STATUS_CANCELED => 'Canceled', - ]), + ->options(FilterOptionCatalog::alertDeliveryStatuses()), SelectFilter::make('event_type') ->label('Event type') ->options(function (): array { @@ -279,6 +279,7 @@ public static function table(Table $table): Table ->pluck('name', 'id') ->all(); }), + FilterPresets::dateRange('created_at', 'Created', 'created_at'), ]) ->actions([ ViewAction::make()->label('View'), diff --git a/app/Filament/Resources/BaselineProfileResource.php b/app/Filament/Resources/BaselineProfileResource.php index 8c1b29a..dd43e36 100644 --- a/app/Filament/Resources/BaselineProfileResource.php +++ b/app/Filament/Resources/BaselineProfileResource.php @@ -16,6 +16,7 @@ use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineFullContentRolloutGate; use App\Support\Baselines\BaselineProfileStatus; +use App\Support\Filament\FilterOptionCatalog; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Rbac\WorkspaceUiEnforcement; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; @@ -351,6 +352,9 @@ public static function table(Table $table): Table TextColumn::make('version_label') ->label('Version') ->placeholder('—'), + TextColumn::make('tenant_assignments_count') + ->label('Assigned tenants') + ->counts('tenantAssignments'), TextColumn::make('activeSnapshot.captured_at') ->label('Last snapshot') ->dateTime() @@ -360,6 +364,10 @@ public static function table(Table $table): Table ->sortable() ->toggleable(isToggledHiddenByDefault: true), ]) + ->filters([ + \Filament\Tables\Filters\SelectFilter::make('status') + ->options(FilterOptionCatalog::baselineProfileStatuses()), + ]) ->actions([ Action::make('view') ->label('View') diff --git a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php index 149060f..81a8d50 100644 --- a/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php +++ b/app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php @@ -29,6 +29,11 @@ class BaselineTenantAssignmentsRelationManager extends RelationManager protected static ?string $title = 'Tenant assignments'; + /** + * @var array|null + */ + protected ?array $tenantAssignmentSummaries = null; + public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { return ActionSurfaceDeclaration::forRelationManager(ActionSurfaceProfile::RelationManager) @@ -79,7 +84,8 @@ private function assignTenantAction(): Action ->form([ Select::make('tenant_id') ->label('Tenant') - ->options(fn (): array => $this->getAvailableTenantOptions()) + ->options(fn (): array => $this->getTenantOptions()) + ->disableOptionWhen(fn (string $value): bool => array_key_exists((int) $value, $this->getTenantAssignmentSummaries())) ->required() ->searchable(), ]) @@ -105,9 +111,13 @@ private function assignTenantAction(): Action ->first(); if ($existing instanceof BaselineTenantAssignment) { + $assignedBaselineName = $this->getAssignedBaselineNameForTenant($tenantId); + Notification::make() ->title('Tenant already assigned') - ->body('This tenant already has a baseline assignment in this workspace.') + ->body($assignedBaselineName === null + ? 'This tenant already has a baseline assignment in this workspace.' + : "This tenant is already assigned to baseline: {$assignedBaselineName}.") ->warning() ->send(); @@ -127,6 +137,8 @@ private function assignTenantAction(): Action ->title('Tenant assigned') ->success() ->send(); + + $this->forgetTenantAssignmentSummaries(); }); } @@ -163,31 +175,99 @@ private function removeAssignmentAction(): Action ->title('Assignment removed') ->success() ->send(); + + $this->forgetTenantAssignmentSummaries(); }); } /** * @return array */ - private function getAvailableTenantOptions(): array + private function getTenantOptions(): array { /** @var BaselineProfile $profile */ $profile = $this->getOwnerRecord(); - $assignedTenantIds = BaselineTenantAssignment::query() + $assignmentSummaries = $this->getTenantAssignmentSummaries(); + + return Tenant::query() ->where('workspace_id', $profile->workspace_id) - ->pluck('tenant_id') + ->orderBy('name') + ->get(['id', 'name']) + ->mapWithKeys(function (Tenant $tenant) use ($assignmentSummaries): array { + $tenantId = (int) $tenant->getKey(); + $assignmentSummary = $assignmentSummaries[$tenantId] ?? null; + + return [ + $tenantId => $this->formatTenantOptionLabel($tenant, $assignmentSummary), + ]; + }) ->all(); + } - $query = Tenant::query() - ->where('workspace_id', $profile->workspace_id) - ->orderBy('name'); - - if (! empty($assignedTenantIds)) { - $query->whereNotIn('id', $assignedTenantIds); + /** + * @return array + */ + private function getTenantAssignmentSummaries(): array + { + if (is_array($this->tenantAssignmentSummaries)) { + return $this->tenantAssignmentSummaries; } - return $query->pluck('name', 'id')->all(); + /** @var BaselineProfile $profile */ + $profile = $this->getOwnerRecord(); + + $this->tenantAssignmentSummaries = BaselineTenantAssignment::query() + ->where('workspace_id', (int) $profile->workspace_id) + ->with('baselineProfile:id,name') + ->get(['tenant_id', 'baseline_profile_id']) + ->mapWithKeys(function (BaselineTenantAssignment $assignment): array { + $baselineProfile = $assignment->baselineProfile; + + return [ + (int) $assignment->tenant_id => [ + 'baseline_profile_id' => (int) $assignment->baseline_profile_id, + 'baseline_profile_name' => $baselineProfile instanceof BaselineProfile + ? (string) $baselineProfile->name + : 'another baseline profile', + ], + ]; + }) + ->all(); + + return $this->tenantAssignmentSummaries; + } + + /** + * @param array{baseline_profile_id:int, baseline_profile_name:string}|null $assignmentSummary + */ + private function formatTenantOptionLabel( + Tenant $tenant, + ?array $assignmentSummary, + ): string { + $tenantName = (string) $tenant->name; + + if ($assignmentSummary === null) { + return $tenantName; + } + + return "{$tenantName} (assigned to baseline: {$assignmentSummary['baseline_profile_name']})"; + } + + private function getAssignedBaselineNameForTenant(int $tenantId): ?string + { + $assignmentSummary = $this->getTenantAssignmentSummaries()[$tenantId] ?? null; + + if ($assignmentSummary === null) { + return null; + } + + return $assignmentSummary['baseline_profile_name']; + } + + private function forgetTenantAssignmentSummaries(): void + { + $this->tenantAssignmentSummaries = null; } private function auditAssignment( diff --git a/app/Filament/Resources/EntraGroupResource.php b/app/Filament/Resources/EntraGroupResource.php index 2f4ca8d..b1fe70e 100644 --- a/app/Filament/Resources/EntraGroupResource.php +++ b/app/Filament/Resources/EntraGroupResource.php @@ -7,6 +7,7 @@ use App\Models\Tenant; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Filament\TablePaginationProfiles; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -96,7 +97,10 @@ public static function table(Table $table): Table { return $table ->defaultSort('display_name') - ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->paginated(TablePaginationProfiles::resource()) + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() ->modifyQueryUsing(function (Builder $query): Builder { $tenantId = Tenant::current()?->getKey(); @@ -149,7 +153,6 @@ public static function table(Table $table): Table return $query->where('last_seen_at', '>=', $cutoff); }), - SelectFilter::make('group_type') ->label('Type') ->options([ diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index a81c965..51c34f4 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -14,6 +14,8 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Filament\FilterOptionCatalog; +use App\Support\Filament\FilterPresets; use App\Support\Filament\TablePaginationProfiles; use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiTooltips; @@ -529,16 +531,7 @@ public static function table(Table $table): Table return $query->where('assignee_user_id', (int) $userId); }), Tables\Filters\SelectFilter::make('status') - ->options([ - Finding::STATUS_NEW => 'New', - Finding::STATUS_TRIAGED => 'Triaged', - Finding::STATUS_ACKNOWLEDGED => 'Triaged (legacy acknowledged)', - Finding::STATUS_IN_PROGRESS => 'In progress', - Finding::STATUS_REOPENED => 'Reopened', - Finding::STATUS_RESOLVED => 'Resolved', - Finding::STATUS_CLOSED => 'Closed', - Finding::STATUS_RISK_ACCEPTED => 'Risk accepted', - ]) + ->options(FilterOptionCatalog::findingStatuses()) ->label('Status'), Tables\Filters\SelectFilter::make('finding_type') ->options([ @@ -592,6 +585,7 @@ public static function table(Table $table): Table return $query; }), + FilterPresets::dateRange('created_at', 'Created', 'created_at'), ]) ->actions([ Actions\ViewAction::make(), diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 273f1ae..aacb7d2 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -16,6 +16,7 @@ use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use App\Support\Enums\RelationshipType; +use App\Support\Filament\FilterOptionCatalog; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; @@ -204,10 +205,7 @@ public static function infolist(Schema $schema): Schema public static function table(Table $table): Table { - $typeOptions = collect(static::allTypeMeta()) - ->mapWithKeys(fn (array $row) => [($row['type'] ?? null) => ($row['label'] ?? $row['type'] ?? null)]) - ->filter(fn ($label, $type) => is_string($type) && $type !== '' && is_string($label) && $label !== '') - ->all(); + $typeOptions = FilterOptionCatalog::policyTypes(); $categoryOptions = collect(static::allTypeMeta()) ->mapWithKeys(fn (array $row) => [($row['category'] ?? null) => ($row['category'] ?? null)]) @@ -217,6 +215,9 @@ public static function table(Table $table): Table return $table ->defaultSort('last_seen_at', 'desc') ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() ->columns([ Tables\Columns\TextColumn::make('display_name') ->label('Name') @@ -279,6 +280,34 @@ public static function table(Table $table): Table Tables\Filters\SelectFilter::make('category') ->options($categoryOptions) ->searchable(), + Tables\Filters\SelectFilter::make('platform') + ->options(FilterOptionCatalog::platforms()) + ->searchable(), + Tables\Filters\SelectFilter::make('stale') + ->label('Freshness') + ->options([ + '0' => 'Fresh', + '1' => 'Stale', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + $cutoff = now()->subHours(max(1, (int) config('tenantpilot.hardening.intune_write_gate.freshness_threshold_hours', 24))); + + if ((string) $value === '1') { + return $query->where(function (Builder $staleQuery) use ($cutoff): void { + $staleQuery + ->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $cutoff); + }); + } + + return $query->where('last_seen_at', '>=', $cutoff); + }), ]) ->recordUrl(static fn (Model $record): ?string => static::canView($record) ? static::getUrl('view', ['record' => $record]) diff --git a/app/Filament/Resources/OperationRunResource.php b/app/Filament/Resources/OperationRunResource.php index 98aefea..66c498a 100644 --- a/app/Filament/Resources/OperationRunResource.php +++ b/app/Filament/Resources/OperationRunResource.php @@ -11,6 +11,8 @@ use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Baselines\BaselineCompareReasonCode; +use App\Support\Filament\FilterOptionCatalog; +use App\Support\Filament\FilterPresets; use App\Support\Filament\TablePaginationProfiles; use App\Support\OperateHub\OperateHubShell; use App\Support\OperationCatalog; @@ -29,7 +31,6 @@ use BackedEnum; use Filament\Actions; use Filament\Facades\Filament; -use Filament\Forms\Components\DatePicker; use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\ViewEntry; use Filament\Resources\Resource; @@ -603,13 +604,15 @@ public static function table(Table $table): Table return []; } - return OperationRun::query() + $types = OperationRun::query() ->where('workspace_id', (int) $workspaceId) ->select('type') ->distinct() ->orderBy('type') ->pluck('type', 'type') ->all(); + + return FilterOptionCatalog::operationTypes(array_keys($types)); }), Tables\Filters\SelectFilter::make('status') ->options([ @@ -644,31 +647,10 @@ public static function table(Table $table): Table ->all(); }) ->searchable(), - Tables\Filters\Filter::make('created_at') - ->label('Created') - ->form([ - DatePicker::make('created_from') - ->label('From'), - DatePicker::make('created_until') - ->label('Until'), - ]) - ->default(fn (): array => [ - 'created_from' => now()->subDays(30), - 'created_until' => now(), - ]) - ->query(function (Builder $query, array $data): Builder { - $from = $data['created_from'] ?? null; - if ($from) { - $query->whereDate('created_at', '>=', $from); - } - - $until = $data['created_until'] ?? null; - if ($until) { - $query->whereDate('created_at', '<=', $until); - } - - return $query; - }), + FilterPresets::dateRange('created_at', 'Created', 'created_at', [ + 'from' => now()->subDays(30)->toDateString(), + 'until' => now()->toDateString(), + ]), ]) ->actions([ Actions\ViewAction::make() diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 04a3cb3..5f33ca9 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -21,6 +21,8 @@ use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use App\Support\Baselines\PolicyVersionCapturePurpose; +use App\Support\Filament\FilterOptionCatalog; +use App\Support\Filament\FilterPresets; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\Rbac\UiEnforcement; @@ -482,6 +484,9 @@ public static function table(Table $table): Table return $table ->defaultSort('captured_at', 'desc') ->paginated(\App\Support\Filament\TablePaginationProfiles::resource()) + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() ->columns([ Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(), Tables\Columns\TextColumn::make('version_number')->sortable(), @@ -497,11 +502,15 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(), ]) ->filters([ - TrashedFilter::make() - ->label('Archived') - ->placeholder('Active') - ->trueLabel('All') - ->falseLabel('Archived'), + Tables\Filters\SelectFilter::make('policy_type') + ->label('Type') + ->options(FilterOptionCatalog::policyTypes()) + ->searchable(), + Tables\Filters\SelectFilter::make('platform') + ->options(FilterOptionCatalog::platforms()) + ->searchable(), + FilterPresets::dateRange('captured_at', 'Captured', 'captured_at'), + FilterPresets::archived(), ]) ->recordUrl(static fn (PolicyVersion $record): ?string => static::canView($record) ? static::getUrl('view', ['record' => $record]) diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 9a81c8c..1dc5bed 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -27,6 +27,8 @@ use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Filament\FilterOptionCatalog; +use App\Support\Filament\FilterPresets; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -52,6 +54,7 @@ use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\QueryException; @@ -234,6 +237,22 @@ public static function makeCreateAction(): Actions\CreateAction return $action; } + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()?->getKey(); + + return parent::getEloquentQuery() + ->with('backupSet') + ->when( + $tenantId !== null, + fn (Builder $query): Builder => $query->where('tenant_id', (int) $tenantId), + ) + ->when( + $tenantId === null, + fn (Builder $query): Builder => $query->whereRaw('1 = 0'), + ); + } + /** * @return array */ @@ -754,15 +773,22 @@ public static function getWizardSteps(): array public static function table(Table $table): Table { return $table + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() ->columns([ - Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'), + Tables\Columns\TextColumn::make('backupSet.name') + ->label('Backup set') + ->searchable() + ->sortable(), Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(), Tables\Columns\TextColumn::make('status') ->badge() ->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus)) ->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)), + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus)) + ->sortable(), Tables\Columns\TextColumn::make('summary_total') ->label('Total') ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)), @@ -772,16 +798,38 @@ public static function table(Table $table): Table Tables\Columns\TextColumn::make('summary_failed') ->label('Failed') ->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)), - Tables\Columns\TextColumn::make('started_at')->dateTime()->since(), - Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(), - Tables\Columns\TextColumn::make('requested_by')->label('Requested by'), + Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(), + Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(), + Tables\Columns\TextColumn::make('requested_by')->label('Requested by')->searchable()->sortable(), ]) ->filters([ - TrashedFilter::make() - ->label('Archived') - ->placeholder('Active') - ->trueLabel('All') - ->falseLabel('Archived'), + Tables\Filters\SelectFilter::make('status') + ->options(FilterOptionCatalog::restoreRunStatuses()) + ->searchable(), + Tables\Filters\SelectFilter::make('outcome') + ->options(FilterOptionCatalog::restoreRunOutcomes()) + ->query(function (\Illuminate\Database\Eloquent\Builder $query, array $data): \Illuminate\Database\Eloquent\Builder { + $value = $data['value'] ?? null; + + return match ((string) $value) { + 'succeeded' => $query->whereIn('status', [ + \App\Support\RestoreRunStatus::Previewed->value, + \App\Support\RestoreRunStatus::Completed->value, + ]), + 'partial' => $query->whereIn('status', [ + \App\Support\RestoreRunStatus::Partial->value, + \App\Support\RestoreRunStatus::CompletedWithErrors->value, + ]), + 'failed' => $query->whereIn('status', [ + \App\Support\RestoreRunStatus::Failed->value, + \App\Support\RestoreRunStatus::Cancelled->value, + \App\Support\RestoreRunStatus::Aborted->value, + ]), + default => $query, + }; + }), + FilterPresets::dateRange('started_at', 'Started', 'started_at'), + FilterPresets::archived(), ]) ->actions([ Actions\ViewAction::make(), diff --git a/app/Support/Filament/FilterOptionCatalog.php b/app/Support/Filament/FilterOptionCatalog.php new file mode 100644 index 0000000..d1fe400 --- /dev/null +++ b/app/Support/Filament/FilterOptionCatalog.php @@ -0,0 +1,205 @@ + + */ + public static function alertDeliveryStatuses(): array + { + return self::badgeOptions(BadgeDomain::AlertDeliveryStatus, [ + AlertDelivery::STATUS_QUEUED, + AlertDelivery::STATUS_DEFERRED, + AlertDelivery::STATUS_SENT, + AlertDelivery::STATUS_FAILED, + AlertDelivery::STATUS_SUPPRESSED, + AlertDelivery::STATUS_CANCELED, + ]); + } + + /** + * @return array + */ + public static function baselineProfileStatuses(): array + { + return collect(BaselineProfileStatus::cases()) + ->mapWithKeys(fn (BaselineProfileStatus $status): array => [ + $status->value => $status->label(), + ]) + ->all(); + } + + /** + * @return array + */ + public static function findingStatuses(bool $includeLegacyAcknowledged = true): array + { + $options = self::badgeOptions(BadgeDomain::FindingStatus, [ + Finding::STATUS_NEW, + Finding::STATUS_TRIAGED, + Finding::STATUS_IN_PROGRESS, + Finding::STATUS_REOPENED, + Finding::STATUS_RESOLVED, + Finding::STATUS_CLOSED, + Finding::STATUS_RISK_ACCEPTED, + ]); + + if (! $includeLegacyAcknowledged) { + return $options; + } + + return [ + Finding::STATUS_NEW => $options[Finding::STATUS_NEW], + Finding::STATUS_TRIAGED => $options[Finding::STATUS_TRIAGED], + Finding::STATUS_ACKNOWLEDGED => self::legacyFindingAcknowledgedLabel(), + Finding::STATUS_IN_PROGRESS => $options[Finding::STATUS_IN_PROGRESS], + Finding::STATUS_REOPENED => $options[Finding::STATUS_REOPENED], + Finding::STATUS_RESOLVED => $options[Finding::STATUS_RESOLVED], + Finding::STATUS_CLOSED => $options[Finding::STATUS_CLOSED], + Finding::STATUS_RISK_ACCEPTED => $options[Finding::STATUS_RISK_ACCEPTED], + ]; + } + + /** + * @param iterable|null $types + * @return array + */ + public static function operationTypes(?iterable $types = null): array + { + $values = collect($types ?? array_keys(OperationCatalog::labels())) + ->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '') + ->map(fn (string $type): string => trim($type)) + ->unique() + ->sort() + ->values(); + + return $values + ->mapWithKeys(fn (string $type): array => [$type => OperationCatalog::label($type)]) + ->sort() + ->all(); + } + + /** + * @param iterable|null $types + * @return array + */ + public static function policyTypes(?iterable $types = null): array + { + $values = $types === null + ? collect(InventoryPolicyTypeMeta::all())->pluck('type') + : collect($types); + + return $values + ->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '') + ->map(fn (string $type): string => trim($type)) + ->unique() + ->mapWithKeys(function (string $type): array { + $label = InventoryPolicyTypeMeta::metaFor($type)['label'] ?? null; + + return [$type => is_string($label) && $label !== '' ? $label : $type]; + }) + ->sort() + ->all(); + } + + /** + * @param iterable|null $platforms + * @return array + */ + public static function platforms(?iterable $platforms = null): array + { + $values = collect($platforms ?? []) + ->merge(collect(InventoryPolicyTypeMeta::all())->pluck('platform')) + ->merge(['all', 'android', 'ios', 'iOS', 'ipadOS', 'macOS', 'macos', 'mobile', 'windows', 'windows10', 'windows11']) + ->filter(fn (mixed $platform): bool => is_string($platform) && trim($platform) !== '') + ->map(fn (string $platform): string => trim($platform)) + ->unique() + ->sort() + ->values(); + + return $values + ->mapWithKeys(fn (string $platform): array => [$platform => self::platformLabel($platform)]) + ->sort() + ->all(); + } + + /** + * @return array + */ + public static function restoreRunOutcomes(): array + { + return [ + 'succeeded' => 'Succeeded', + 'partial' => 'Partial', + 'failed' => 'Failed', + ]; + } + + /** + * @return array + */ + public static function restoreRunStatuses(): array + { + return self::badgeOptions(BadgeDomain::RestoreRunStatus, [ + RestoreRunStatus::Draft->value, + RestoreRunStatus::Scoped->value, + RestoreRunStatus::Checked->value, + RestoreRunStatus::Previewed->value, + RestoreRunStatus::Pending->value, + RestoreRunStatus::Queued->value, + RestoreRunStatus::Running->value, + RestoreRunStatus::Completed->value, + RestoreRunStatus::Partial->value, + RestoreRunStatus::Failed->value, + RestoreRunStatus::Cancelled->value, + RestoreRunStatus::Aborted->value, + RestoreRunStatus::CompletedWithErrors->value, + ]); + } + + /** + * @param array $values + * @return array + */ + private static function badgeOptions(BadgeDomain $domain, array $values): array + { + return collect($values) + ->mapWithKeys(fn (string $value): array => [$value => BadgeCatalog::spec($domain, $value)->label]) + ->all(); + } + + private static function legacyFindingAcknowledgedLabel(): string + { + return BadgeCatalog::spec(BadgeDomain::FindingStatus, Finding::STATUS_ACKNOWLEDGED)->label.' (legacy acknowledged)'; + } + + private static function platformLabel(string $platform): string + { + return match (Str::of($platform) + ->trim() + ->lower() + ->replace(['_', '-'], '') + ->toString()) { + 'windows', 'windows10', 'windows11' => 'Windows', + 'ios', 'ipados' => 'iOS', + 'macos' => 'macOS', + default => InventoryPolicyTypeMeta::all() + ? (string) \App\Support\Badges\TagBadgeCatalog::spec(\App\Support\Badges\TagBadgeDomain::Platform, $platform)->label + : $platform, + }; + } +} diff --git a/app/Support/Filament/FilterPresets.php b/app/Support/Filament/FilterPresets.php new file mode 100644 index 0000000..ed98fc2 --- /dev/null +++ b/app/Support/Filament/FilterPresets.php @@ -0,0 +1,86 @@ +label('Archived') + ->placeholder('Active') + ->trueLabel('All') + ->falseLabel('Archived'); + } + + /** + * @param array|null $default + */ + public static function dateRange(string $name, string $label, string $column, ?array $default = null): Filter + { + $filter = Filter::make($name) + ->label($label) + ->schema([ + DatePicker::make('from') + ->label('From'), + DatePicker::make('until') + ->label('Until'), + ]) + ->query(function (Builder $query, array $data) use ($column): Builder { + $from = $data['from'] ?? null; + if ($from) { + $query->whereDate($column, '>=', $from); + } + + $until = $data['until'] ?? null; + if ($until) { + $query->whereDate($column, '<=', $until); + } + + return $query; + }) + ->indicateUsing(function (array $data) use ($label): ?array { + $indicators = []; + $from = self::formatIndicatorDate($data['from'] ?? null); + $until = self::formatIndicatorDate($data['until'] ?? null); + + if ($from !== null) { + $indicators[] = "{$label} from {$from}"; + } + + if ($until !== null) { + $indicators[] = "{$label} until {$until}"; + } + + return $indicators === [] ? null : $indicators; + }); + + if ($default !== null) { + $filter = $filter->default($default); + } + + return $filter; + } + + private static function formatIndicatorDate(mixed $value): ?string + { + if (! is_string($value) || trim($value) === '') { + return null; + } + + try { + return CarbonImmutable::parse($value)->toFormattedDateString(); + } catch (Throwable) { + return null; + } + } +} diff --git a/specs/126-filter-ux-standardization/checklists/requirements.md b/specs/126-filter-ux-standardization/checklists/requirements.md new file mode 100644 index 0000000..6ca9592 --- /dev/null +++ b/specs/126-filter-ux-standardization/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Filter UX Standardization + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-09 +**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 + +- Validation pass completed against the initial authored draft. +- The spec intentionally keeps the solution bounded to list behavior, shared vocabulary, and regression guards rather than a broader filter-system redesign. \ No newline at end of file diff --git a/specs/126-filter-ux-standardization/contracts/filament-filter-state.openapi.yaml b/specs/126-filter-ux-standardization/contracts/filament-filter-state.openapi.yaml new file mode 100644 index 0000000..95726a9 --- /dev/null +++ b/specs/126-filter-ux-standardization/contracts/filament-filter-state.openapi.yaml @@ -0,0 +1,180 @@ +openapi: 3.1.0 +info: + title: Filament Filter State Standardization Contract + version: 1.0.0 + description: | + Page-level contract for standardized Filament list filter behavior affected by Spec 126. + This feature does not add public APIs. It formalizes the expected query-state, + persistence, and filtering behaviors for in-scope resource list pages. +paths: + /admin/{listSlug}: + get: + summary: Render a workspace-scoped Filament list with standardized filter behavior + operationId: getWorkspaceScopedFilteredList + tags: + - Filament Filter UX + parameters: + - $ref: '#/components/parameters/ListSlug' + - $ref: '#/components/parameters/TableSearch' + - $ref: '#/components/parameters/TableSortColumn' + - $ref: '#/components/parameters/TableSortDirection' + - $ref: '#/components/parameters/TableFilters' + - $ref: '#/components/parameters/Page' + responses: + '200': + description: Filtered list page rendered successfully + content: + text/html: + schema: + type: string + '403': + description: Actor is a member of the scope but lacks the capability required for page actions + '404': + description: Workspace scope is unavailable or the actor is not entitled to the requested scope + /admin/t/{tenant}/{listSlug}: + get: + summary: Render a tenant-scoped Filament list with standardized filter behavior + operationId: getTenantScopedFilteredList + tags: + - Filament Filter UX + parameters: + - name: tenant + in: path + required: true + schema: + type: string + description: Tenant identifier resolved by existing Filament tenant route binding + - $ref: '#/components/parameters/ListSlug' + - $ref: '#/components/parameters/TableSearch' + - $ref: '#/components/parameters/TableSortColumn' + - $ref: '#/components/parameters/TableSortDirection' + - $ref: '#/components/parameters/TableFilters' + - $ref: '#/components/parameters/Page' + responses: + '200': + description: Tenant-scoped filtered list page rendered successfully + content: + text/html: + schema: + type: string + '403': + description: Tenant member lacks capability required for the page or exposed actions + '404': + description: Tenant or workspace context is unavailable or the actor is not entitled to the tenant scope +components: + parameters: + ListSlug: + name: listSlug + in: path + required: true + schema: + type: string + description: Logical list identifier for an in-scope Filament resource list + TableSearch: + name: tableSearch + in: query + required: false + schema: + type: string + description: Free-text table search across approved searchable fields + TableSortColumn: + name: tableSortColumn + in: query + required: false + schema: + type: string + description: Currently selected sort column + TableSortDirection: + name: tableSortDirection + in: query + required: false + schema: + type: string + enum: + - asc + - desc + description: Currently selected sort direction + TableFilters: + name: tableFilters + in: query + required: false + style: deepObject + explode: true + schema: + type: object + additionalProperties: true + description: | + Native Filament filter state, including status, outcome, platform, policy_type, + stale, archived, and date-range inputs such as `from` and `until`. + Page: + name: page + in: query + required: false + schema: + type: integer + minimum: 1 + description: Current paginator page + schemas: + ResourceFilterProfile: + type: object + required: + - persistence + - archivedVisibility + - centralStatusSource + properties: + persistence: + type: object + required: + - search + - sort + - filters + properties: + search: + type: boolean + sort: + type: boolean + filters: + type: boolean + archivedVisibility: + type: object + properties: + enabled: + type: boolean + label: + type: string + enum: + - Archived + placeholder: + type: string + enum: + - Active + trueLabel: + type: string + enum: + - All + falseLabel: + type: string + enum: + - Archived + centralStatusSource: + type: object + properties: + required: + type: boolean + sourceKind: + type: string + enum: + - enum + - badge_catalog + - tag_badge_catalog + - config_registry + - static_helper + dateRange: + type: object + properties: + enabled: + type: boolean + column: + type: string + indicatorSupport: + type: boolean \ No newline at end of file diff --git a/specs/126-filter-ux-standardization/data-model.md b/specs/126-filter-ux-standardization/data-model.md new file mode 100644 index 0000000..d6dc4a3 --- /dev/null +++ b/specs/126-filter-ux-standardization/data-model.md @@ -0,0 +1,132 @@ +# Data Model: Filter UX Standardization + +## Overview + +This feature introduces no new persistent storage. It defines a conceptual model for how in-scope Filament resource lists are classified, standardized, and protected against drift. + +## Entities + +### FilterTier + +- Purpose: Classifies a resource list by rollout priority and required filter behavior. +- Source: The feature spec’s Tier 1, Tier 2, and Tier 3 model. + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `tier_key` | enum(`tier_1`,`tier_2`,`tier_3`) | yes | Standard priority bucket for the list | +| `criticality` | enum(`critical`,`important`,`standard`) | yes | Human-readable priority level | +| `requires_persistence` | boolean | yes | Whether search, sort, and filter state must persist | +| `requires_core_filters` | boolean | yes | Whether domain-appropriate filters are mandatory | +| `requires_date_range_when_time_based` | boolean | yes | Whether time-series lists in the tier must expose date range | +| `notes` | string nullable | no | Explanatory notes for exceptions or optional scope | + +#### Validation Rules + +- Tier 1 and Tier 2 must set `requires_persistence` to true. +- Tier 3 must remain optional unless elevated by a later spec. + +### ResourceFilterProfile + +- Purpose: Represents the agreed filter contract for one resource list. +- Source: Existing Filament resource tables such as `FindingResource`, `PolicyVersionResource`, `AlertDeliveryResource`, and peers. + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `resource_class` | string | yes | Backing Filament resource class | +| `route_scope` | enum(`tenant`,`workspace`,`canonical_view`) | yes | Scope model used by the resource list | +| `data_ownership` | enum(`tenant_owned`,`workspace_owned`,`mixed`) | yes | Ownership context of listed records | +| `tier` | reference `FilterTier` | yes | Required behavior level | +| `has_filters` | boolean | yes | Whether the list exposes at least one filter | +| `has_persistence_trio` | boolean | yes | Whether filters, search, and sort persist in session | +| `supports_soft_delete` | boolean | yes | Whether the resource is soft deletable | +| `uses_standard_archived_filter` | boolean | yes | Whether the standard archive filter is present | +| `uses_central_status_source` | boolean | yes | Whether prioritized status/outcome filters derive from a central source | +| `has_date_range_filter` | boolean | yes | Whether the list exposes a date-range filter | +| `date_range_column` | string nullable | no | Primary time column used for the filter | +| `default_filter_behavior` | string nullable | no | Optional smart default such as open-only or active-only | +| `query_risk` | enum(`low`,`medium`,`high`) | yes | Risk level for added filter/query complexity | +| `exception_reason` | string nullable | no | Required when the standard is intentionally not fully applied | + +#### Validation Rules + +- If `tier` is Tier 1 or Tier 2 and `has_filters` is true, `has_persistence_trio` must be true. +- If `supports_soft_delete` is true, `uses_standard_archived_filter` must be true unless `exception_reason` is populated. +- If the list is time-based and in Tier 1 or Tier 2, `has_date_range_filter` must be true unless `exception_reason` is populated. + +### FilterDimension + +- Purpose: Describes one business-relevant filter dimension on a list. + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `dimension_key` | string | yes | Stable filter identifier such as `status`, `outcome`, `platform`, or `date_range` | +| `filter_type` | enum(`select`,`trashed`,`ternary`,`custom`,`date_range`) | yes | Native Filament filter type | +| `label` | string | yes | User-facing label | +| `source_type` | enum(`enum`,`catalog`,`model_constant`,`query_distinct`,`custom_query`) | yes | Origin of the filter options or behavior | +| `is_priority_dimension` | boolean | yes | Whether this is a core required filter for the list | +| `has_indicator_support` | boolean | yes | Whether active filter indicators appear in the summary UI | +| `query_cost` | enum(`low`,`medium`,`high`) | yes | Estimated cost of applying the filter | + +#### Validation Rules + +- `date_range` filters must set `has_indicator_support` to true. +- `status` and `outcome` dimensions on prioritized resources should use `enum` or `catalog` source types where possible. + +### CentralOptionSource + +- Purpose: Represents the shared vocabulary source used by a filter dimension. + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `source_key` | string | yes | Identifier such as `BaselineProfileStatus`, `BadgeDomain::FindingStatus`, or `TagBadgeDomain::Platform` | +| `source_kind` | enum(`enum`,`badge_catalog`,`tag_badge_catalog`,`config_registry`,`static_helper`) | yes | Kind of central option source | +| `provides_labels` | boolean | yes | Whether the source exposes user-facing labels | +| `provides_ordering` | boolean | yes | Whether the source defines preferred option order | +| `requires_adapter` | boolean | yes | Whether a thin helper is needed to convert the source into filter options | + +#### Notes + +- `BaselineProfileStatus` is already an enum-backed source. +- `FindingStatus` and `AlertDeliveryStatus` currently have centralized badge semantics but may need a thin adapter to become filter option sources cleanly. +- Policy type and platform labels may continue to come from existing config or tag-badge catalogs. + +### FilterGuardRule + +- Purpose: Defines one regression rule enforced in tests. + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `rule_key` | string | yes | Stable guard identifier | +| `rule_type` | enum(`persistence`,`archive_visibility`,`central_status_source`,`functional_behavior`,`scope_safety`) | yes | What the rule protects | +| `coverage_level` | enum(`static_guard`,`livewire_behavior`,`feature_scope`) | yes | Test style used to enforce the rule | +| `applies_to` | list | yes | Resource classes or test targets covered | +| `failure_mode` | string | yes | What kind of regression this rule should catch | + +## Relationships + +- One `FilterTier` applies to many `ResourceFilterProfile` records. +- One `ResourceFilterProfile` contains many `FilterDimension` definitions. +- One `FilterDimension` may depend on one `CentralOptionSource`. +- One `FilterGuardRule` may protect many `ResourceFilterProfile` records. + +## State Transitions + +- Audit: Record the current `ResourceFilterProfile` for each in-scope list. +- Standardization: Add missing persistence, archive visibility, date-range filters, and essential dimensions. +- Alignment: Swap prioritized local status arrays for central option sources or thin adapters. +- Guarding: Extend `FilterGuardRule` coverage so the standardized state cannot drift silently. + +## Notes + +- No migrations, new tables, or new Eloquent models are required. +- This conceptual model exists to support planning, implementation sequencing, and regression protection. \ No newline at end of file diff --git a/specs/126-filter-ux-standardization/plan.md b/specs/126-filter-ux-standardization/plan.md new file mode 100644 index 0000000..4e8c0c6 --- /dev/null +++ b/specs/126-filter-ux-standardization/plan.md @@ -0,0 +1,184 @@ +# Implementation Plan: Filter UX Standardization + +**Branch**: `126-filter-ux-standardization` | **Date**: 2026-03-09 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/126-filter-ux-standardization/spec.md` + +**Note**: This plan is generated by the `/speckit.plan` workflow and aligned to the current repository constitution. + +## Summary + +Standardize filter UX across TenantPilot’s important Filament list surfaces using native Filament filters, native session persistence, and light regression guards. The implementation will add the persistence trio to all Tier 1 and Tier 2 filtered resource lists, expand consistent `TrashedFilter` usage, add missing date-range and essential domain filters on the highest-value surfaces, align prioritized status filters to centralized vocabularies, and extend existing Pest guard and Livewire coverage without introducing a custom filter framework. A thin shared helper layer is intentionally selected for repeated option-source, archived-filter, and date-range mechanics because those repetitions are already proven across the target surfaces. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4.0+, Tailwind CSS v4, Pest v4, existing `BadgeCatalog` / `BadgeRenderer`, existing `TagBadgeCatalog` / `TagBadgeRenderer`, existing Filament resource tables +**Storage**: PostgreSQL remains unchanged; session persistence uses Filament-native session keys and existing workspace/tenant context +**Testing**: Pest v4 feature tests, Livewire component tests for Filament list pages, existing guard tests in `tests/Feature/Guards`, and focused manual QA for persistence and date-range indicators +**Target Platform**: Laravel Sail web application with Filament admin and system panels under `/admin`, `/admin/t/{tenant}/...`, and `/system` +**Project Type**: Laravel monolith / Filament web application +**Performance Goals**: No material query regression on existing list pages; new date-range and status filters must compose with existing scopes without introducing obvious N+1 or high-cost relation queries; list-state persistence remains native and server-driven +**Constraints**: Filament-native first, no plugin dependency, no heavy helper framework, no grouped custom filter UI, no authorization behavior changes, no schema changes, no new asset pipeline work, and any extracted helper must remain thin and mechanically scoped +**Scale/Scope**: 12 Tier 1–2 resource lists define the core standard; 5 immediate persistence-gap resources, 6 immediate essential-filter targets, 2 prioritized status-source alignments, 1 existing table guard suite to expand, and optional low-effort Tier 3 cleanup only after core consistency is stable + +## 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 filtering behavior on existing inventory, backup, governance, directory, and monitoring surfaces; no snapshot or inventory semantics change. | +| Read/write separation | PASS | PASS | No new write workflows or operation starts are introduced. Existing mutations remain unchanged and outside this feature’s scope. | +| Graph contract path | N/A | N/A | No Microsoft Graph calls, `GraphClientInterface` usage, or contract registry changes are involved. | +| Deterministic capabilities | PASS | PASS | No new capabilities, role checks, or permission derivation logic are introduced. Existing capability registries remain authoritative. | +| Workspace + tenant isolation | PASS | PASS | The design explicitly keeps existing workspace and tenant query scopes intact while adding filters and persistence. | +| RBAC-UX authorization semantics | PASS | PASS | Non-member 404 and member-without-capability 403 semantics remain unchanged; filter additions must not widen visibility. | +| Destructive confirmation | PASS / N/A | PASS / N/A | No destructive actions are added or modified. Existing destructive actions remain subject to `->requiresConfirmation()`. | +| Global search tenant safety | PASS | PASS | This feature does not expand global search. In-scope resources that are globally searchable already have View pages; resources not intended for global search remain unchanged. | +| Tenant isolation | PASS | PASS | Tenant-owned list queries remain tenant-scoped, and workspace-scoped canonical monitoring screens remain entitlement-safe. | +| Run observability / Ops-UX | N/A | N/A | No new long-running, remote, or queued work is introduced. | +| Data minimization and safe logging | PASS | PASS | No new persistence model or logging payloads are added. | +| BADGE-001 centralized semantics | PASS | PASS | Status and outcome vocabularies are aligned through existing central badge/catalog infrastructure or thin option-source additions, not ad-hoc local arrays. | +| Filament Action Surface Contract | PASS | PASS | The feature changes list filters only. Existing header, row, bulk, empty-state, and detail actions remain resource-local. | +| UX-001 table obligations | PASS | PASS | The feature directly strengthens the table-filter portion of UX-001 by standardizing important list filtering and persistence. | +| Filament v5 / Livewire v4 compliance | PASS | PASS | All work remains within Filament v5 and Livewire v4-native APIs. | +| Panel provider registration | PASS | PASS | No panel-provider changes are needed; Laravel 11+ provider registration remains in `bootstrap/providers.php`. | +| Asset strategy | PASS | PASS | No new global or on-demand assets are required. Existing deploy-time `php artisan filament:assets` expectations remain unchanged. | + +## Implementation Notes + +- Livewire v4.0+ compliance: All planned filters, session persistence, and list tests use existing Filament v5 and Livewire v4 patterns already present in the repo. +- Provider registration location: Unchanged. Filament panel providers remain registered in `bootstrap/providers.php`. +- Global search rule: This feature does not make new resources globally searchable. In-scope resources with existing View pages such as `FindingResource`, `PolicyVersionResource`, `RestoreRunResource`, `ProviderConnectionResource`, `InventoryItemResource`, `AlertDeliveryResource`, and `EntraGroupResource` remain compliant if searched; `OperationRunResource` remains a non-navigation canonical resource and is not being expanded for global search here. +- Destructive actions: No new destructive actions are added. Existing destructive actions on changed resources must keep their current authorization and `->requiresConfirmation()` handling. +- Asset strategy: No new assets. Deployment continues to rely on the existing Filament asset process, including `php artisan filament:assets` where already required. +- Testing plan: Extend `FilamentTableStandardsGuardTest`, add focused filter behavior and persistence coverage on changed list pages, and keep representative tenant/workspace scope regression checks. + +## Project Structure + +### Documentation (this feature) + +```text +specs/126-filter-ux-standardization/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── filament-filter-state.openapi.yaml +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ └── Resources/ +│ ├── FindingResource.php +│ ├── InventoryItemResource.php +│ ├── OperationRunResource.php +│ ├── PolicyResource.php +│ ├── TenantResource.php +│ ├── BackupScheduleResource.php +│ ├── BackupSetResource.php +│ ├── RestoreRunResource.php +│ ├── PolicyVersionResource.php +│ ├── ProviderConnectionResource.php +│ ├── AlertDeliveryResource.php +│ ├── EntraGroupResource.php +│ ├── BaselineProfileResource.php +│ ├── AlertRuleResource.php +│ ├── AlertDestinationResource.php +│ └── Workspaces/WorkspaceResource.php +├── Models/ +│ ├── Finding.php +│ ├── AlertDelivery.php +│ └── BaselineProfile.php +└── Support/ + ├── Badges/ + │ ├── BadgeCatalog.php + │ ├── BadgeRenderer.php + │ ├── TagBadgeCatalog.php + │ └── Domains/ + └── Baselines/ + └── BaselineProfileStatus.php + +tests/ +├── Feature/ +│ ├── Guards/ +│ │ └── FilamentTableStandardsGuardTest.php +│ ├── Filament/ +│ │ ├── TableStatePersistenceTest.php +│ │ ├── Findings/ +│ │ ├── Alerts/ +│ │ └── [resource-specific table tests] +│ └── Rbac/ +└── Browser/ + └── [not expected for first pass] +``` + +**Structure Decision**: Keep the work in the existing Laravel/Filament monolith and update resource-local `table()` definitions plus the current guard and feature test suites. The thin shared helper layer should live under `app/Support/Filament/` and remain limited to repeated mechanical presets such as centralized option sourcing plus archive and date-range filters. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| None | Not applicable | The design stays within the constitution and the spec’s no-framework constraint | + +## Approved Exceptions + +- None approved. The only implementation risk that surfaced was `RestoreRunResource` needing an explicit tenant-scoped base query to preserve intended isolation. +- No transitional status-source exceptions remain for `FindingResource` or `AlertDeliveryResource`; both now use the thin shared option catalog. + +## Phase 0 — Research (output: `research.md`) + +See: [research.md](./research.md) + +Research goals: +- Confirm the native Filament patterns already used for persistence, date-range filters, and archive visibility. +- Confirm the current resource-level gaps on the Tier 1 and Tier 2 targets. +- Confirm the existing centralized badge and enum sources that can back status and outcome filter options. +- Confirm the current guard and functional tests that should be extended rather than replaced. + +## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`) + +See: +- [data-model.md](./data-model.md) +- [contracts/filament-filter-state.openapi.yaml](./contracts/filament-filter-state.openapi.yaml) +- [quickstart.md](./quickstart.md) + +Design focus: +- Model filter behavior as a contract over existing resource tables rather than as a new runtime subsystem. +- Keep the standard resource-local and Filament-native, with only thin preset extraction for repeated mechanical patterns. +- Make persistence mandatory on Tier 1 and Tier 2 resource lists while keeping relation managers, widgets, and pickers out of scope unless already justified. +- Treat centralized status vocabularies as existing domain assets that may need thin option helpers rather than broad enum migrations everywhere. +- Preserve query safety and tenancy/workspace boundaries while adding date-range and essential domain filters. + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +### Persistence and guard foundation +- Add `persistFiltersInSession()`, `persistSearchInSession()`, and `persistSortInSession()` to the missing Tier 1 and Tier 2 filtered resource lists. +- Expand `FilamentTableStandardsGuardTest` so Tier 1 and Tier 2 persistence expectations are enforced consistently. + +### Essential filter rollout +- Add the missing date-range and essential domain filters on `RestoreRunResource`, `PolicyVersionResource`, `FindingResource`, `AlertDeliveryResource`, `InventoryItemResource`, and `BaselineProfileResource`. +- Reuse the existing `OperationRunResource` date-range pattern as the native model where feasible. + +### Consistency alignment +- Replace prioritized manual status arrays with centralized option sources, starting with `FindingResource` and `AlertDeliveryResource`. +- Keep archive visibility and recurring labels aligned with the documented filter standard. +- Introduce only the small preset/helper layer already justified by repeated archived-filter, date-range, and centralized-option patterns. + +### Regression protection +- Extend guard coverage for archive visibility and prioritized centralized status sourcing. +- Add focused Livewire and feature tests that prove filters apply, clear, compose, and remain scope-safe on representative surfaces. + +### Verification +- Run focused Pest suites for guards and changed resource table behavior. +- Run Pint on dirty files through Sail. +- Perform manual QA on refresh/navigation persistence, date-range indicators, and archive semantics on representative tenant and workspace lists. + +## Constitution Check (Post-Design) + +Re-check result: PASS. The design introduces no new routes, storage, authorization rules, background work, or asset requirements. It strengthens UX consistency through existing Filament-native table APIs, preserves current RBAC and tenancy boundaries, and keeps the implementation bounded to list behavior plus regression guards. diff --git a/specs/126-filter-ux-standardization/quickstart.md b/specs/126-filter-ux-standardization/quickstart.md new file mode 100644 index 0000000..ee2cba8 --- /dev/null +++ b/specs/126-filter-ux-standardization/quickstart.md @@ -0,0 +1,50 @@ +# Quickstart: Filter UX Standardization + +## Goal + +Bring Tier 1 and Tier 2 Filament resource lists onto a single filter UX standard using native Filament filters, native session persistence, and focused guard coverage. + +## Foundation Steps + +1. Review the current filter standard and comprehensive audit to confirm the Tier 1 and Tier 2 targets. +2. Confirm the missing persistence trio on `InventoryItemResource`, `PolicyVersionResource`, `RestoreRunResource`, `AlertDeliveryResource`, and `EntraGroupResource`. +3. Confirm the current central status vocabularies already available through enums, badge catalogs, or tag-badge catalogs. +4. Confirm `OperationRunResource` as the reference implementation for the native date-range pattern. +5. Identify any resource with query-risk filters before extending search or filtering behavior. + +## First-Wave Implementation Steps + +1. Add `persistFiltersInSession()`, `persistSearchInSession()`, and `persistSortInSession()` to all missing Tier 1 and Tier 2 filtered resource lists. +2. Extend `FilamentTableStandardsGuardTest` so Tier 1 and Tier 2 persistence is enforced consistently. +3. Add essential missing filters to `RestoreRunResource`, `PolicyVersionResource`, `InventoryItemResource`, `FindingResource`, `AlertDeliveryResource`, and `BaselineProfileResource`. +4. Reuse the native `OperationRunResource` date-range shape for new date-range filters and ensure `indicateUsing()` is present. +5. Normalize soft-delete behavior to the existing Archived/Active/All pattern on every in-scope soft-deletable resource list. +6. Replace prioritized local status arrays with centralized option sources backed by the thin shared helper layer where needed. +7. Keep domain-specific filter intent resource-local while using the thin shared helper layer only for repeated mechanical presets. + +## Verification + +### Automated + +```bash +vendor/bin/sail up -d +vendor/bin/sail artisan test --compact tests/Feature/Guards/FilamentTableStandardsGuardTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php +vendor/bin/sail artisan test --compact tests/Feature/Filament/Findings +vendor/bin/sail artisan test --compact tests/Feature/Filament/Alerts +vendor/bin/sail bin pint --dirty --format agent +``` + +### Manual + +1. Narrow an in-scope resource list with search, sort, and filters, refresh the page, and confirm the same state remains. +2. Navigate away from the same list and return within the session to confirm list-state persistence holds. +3. Apply each new date-range filter and confirm the indicator summary shows the active window clearly. +4. Verify Archived, Active, and All semantics on each changed soft-deletable surface. +5. Verify workspace-scoped monitoring lists and tenant-scoped inventory/governance lists do not reveal records outside the current entitlement boundary. + +## Rollback + +- Revert the affected resource `table()` filter definitions and any guard updates that enforce the new standard. +- Keep the thin shared helper layer mechanically scoped and remove only pieces that stop serving repeated archived, date-range, or centralized-option use cases. +- No database rollback is required because the feature introduces no schema changes. \ No newline at end of file diff --git a/specs/126-filter-ux-standardization/research.md b/specs/126-filter-ux-standardization/research.md new file mode 100644 index 0000000..c74e4a8 --- /dev/null +++ b/specs/126-filter-ux-standardization/research.md @@ -0,0 +1,74 @@ +# Research: Filter UX Standardization + +## Decision 1: Use resource-local native Filament filters as the default implementation path + +- Decision: Implement the standard directly in each resource’s existing `table()` definition using native Filament APIs such as `SelectFilter`, `TrashedFilter`, `Filter::make()`, `DatePicker`, `persistFiltersInSession()`, `persistSearchInSession()`, `persistSortInSession()`, and `indicateUsing()`. +- Rationale: The repo already uses these APIs successfully in `OperationRunResource`, `FindingResource`, `PolicyResource`, `TenantResource`, and other resource tables. Keeping filter behavior explicit in each resource matches the convention-first goal and avoids creating a parallel filter configuration system. +- Alternatives considered: + - Build a generic filter DSL or plugin-backed filter framework: rejected because the spec explicitly forbids heavy abstraction and the audit concluded the work is fully achievable with Filament-native APIs. + - Centralize all filter definitions into traits or macros first: rejected because the behavior varies by domain and the review burden would increase immediately. + +## Decision 2: Treat Tier 1 and Tier 2 persistence as mandatory and enforce it with the existing guard suite + +- Decision: Expand the persistence expectation from the current seven-resource enforcement set to all Tier 1 and Tier 2 filtered resource lists. +- Rationale: The audit identified the main consistency gap as missing state persistence on `InventoryItemResource`, `PolicyVersionResource`, `RestoreRunResource`, `AlertDeliveryResource`, and `EntraGroupResource`. The repo already has both native persistence methods and a guard test structure in `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, so the smallest reliable move is to extend that existing enforcement. +- Alternatives considered: + - Leave persistence as a review convention only: rejected because the current drift happened under partial convention and weak enforcement. + - Add persistence to relation managers, pickers, widgets, and system pages in the same pass: rejected because the strongest operator pain is on resource lists and immediate broadening would expand scope unnecessarily. + +## Decision 3: Keep the soft-delete pattern exactly as the existing archive standard + +- Decision: Standardize soft-delete visibility with the current archive pattern: + `TrashedFilter::make()->label('Archived')->placeholder('Active')->trueLabel('All')->falseLabel('Archived')`. +- Rationale: The audit found this pattern already consistent where used, and the product standard file documents it as canonical. Expanding the existing pattern is lower risk than inventing a new wording or behavior. +- Alternatives considered: + - Rename the filter to “Deleted” or “Trashed”: rejected because the current enterprise-facing copy is already standardized as “Archived.” + - Replace soft-delete visibility with a custom boolean or ternary filter: rejected because Filament’s built-in `TrashedFilter` already expresses the desired semantics cleanly. + +## Decision 4: Use centralized vocabularies for status filters, even if the implementation path differs by domain + +- Decision: Prioritized status filters should derive from central domain sources rather than local arrays. For `BaselineProfileResource`, an enum already exists through `BaselineProfileStatus`. For `FindingResource` and `AlertDeliveryResource`, the repo already has centralized badge/domain mappings in `BadgeCatalog` and domain badge classes even though the list filters still use local arrays today. +- Rationale: The audit flags `FindingResource` and `AlertDeliveryResource` as the main inconsistency examples. The repo already centralizes labels and semantics for these statuses in `BadgeCatalog`, `Domains\FindingStatusBadge`, and `Domains\AlertDeliveryStatusBadge`, which means the implementation can align filter options to those existing vocabularies with a thin option-source helper if needed rather than introducing a broad enum migration. +- Alternatives considered: + - Leave the existing manual arrays in place permanently: rejected because that preserves the exact drift the spec is meant to correct. + - Force immediate full enum migrations for findings and alert deliveries: rejected because it may expand scope beyond what is necessary when centralized badge/domain mappings already exist. + +## Decision 5: Reuse the existing OperationRun date-range pattern as the reference implementation + +- Decision: Use the current `OperationRunResource` date-range filter shape as the reference pattern for new date-range filters on `FindingResource`, `AlertDeliveryResource`, `RestoreRunResource`, and `PolicyVersionResource`. +- Rationale: `OperationRunResource` already demonstrates a native Filament `Filter::make()` + `DatePicker` + `query()` + `indicateUsing()` pattern that matches the product standard. Reusing that shape minimizes design risk and keeps the UX consistent. +- Alternatives considered: + - Use custom query-string-only date narrowing: rejected because it would be less discoverable and outside the Filament-native standard. + - Add separate “From” and “Until” filters as independent controls: rejected because the repo standard already prefers a single date-range filter with indicators. + +## Decision 6: Use a thin shared helper for already-proven repeated mechanics + +- Decision: Introduce a very small helper layer under `app/Support/Filament/` for centralized option sourcing plus archive and date-range presets. +- Rationale: The repeated mechanics are already proven in the target rollout set: the archived filter pattern is standardized, the date-range pattern already exists in `OperationRunResource`, and centralized status labels already exist in shared badge/catalog infrastructure. A tiny helper improves consistency without hiding domain-specific filter intent. +- Alternatives considered: + - Keep every resource fully duplicated with no helper: rejected because the same archived/date-range/option-source mechanics would be repeated across multiple Tier 1–2 surfaces with no added clarity. + - Build a broader filter abstraction layer: rejected because it would violate the spec’s convention-first and no-framework constraints. + +## Decision 7: Extend existing feature tests instead of inventing a new test architecture + +- Decision: Build on the current guard and feature tests, especially `FilamentTableStandardsGuardTest`, `TableStatePersistenceTest`, findings list tests, alert delivery tests, and existing resource-specific table tests. +- Rationale: The repo already uses Pest and Livewire effectively for list-table behavior. The smallest robust move is to extend the current suites with filter-specific assertions rather than create a new test layer. +- Alternatives considered: + - Add broad browser-test coverage first: rejected because this feature is mainly about server-driven list behavior that is already well-covered by Pest and Livewire component tests. + - Rely only on guard tests: rejected because functional tests are still needed to prove filters apply, clear, compose, and remain scope-safe. + +## Decision 8: Preserve existing tenancy and canonical workspace-view semantics while adding filters + +- Decision: Treat workspace-scoped monitoring lists and tenant-scoped inventory/governance lists as existing scope boundaries that filters must respect, not reinterpret. +- Rationale: The constitution is explicit that tenantless canonical views under `/admin` still require entitlement-safe behavior. The audit also calls out query considerations on `AlertDeliveryResource` and other workspace-scoped lists. This means filter additions must compose with current scoping queries rather than introduce new cross-tenant selectors casually. +- Alternatives considered: + - Add generic tenant filters to all workspace-context lists: rejected because some canonical views intentionally scope by current entitlement rather than explicit tenant selection. + - Skip workspace-scoped lists from the standard: rejected because `AlertDeliveryResource`, `ProviderConnectionResource`, `TenantResource`, and `OperationRunResource` are central to the consistency gap. + +## Decision 9: Ship without rollout exceptions and fix discovered scope issues directly + +- Decision: Do not approve transitional query-risk or centralized-status exceptions for this rollout. Fix any surfaced scope issue in-place before calling the standard complete. +- Rationale: The implementation surfaced a tenant-isolation gap on `RestoreRunResource`, and the correct response was to make the base query tenant-scoped rather than document a temporary exception. Likewise, centralized option sources already existed strongly enough to move `FindingResource` and `AlertDeliveryResource` onto the shared helper without deferring that alignment. +- Alternatives considered: + - Approve a temporary scope exception for `RestoreRunResource`: rejected because the gap affected a core boundary and had a straightforward corrective path. + - Leave findings or alert deliveries on local status arrays temporarily: rejected because it would preserve the exact drift this rollout is meant to remove. diff --git a/specs/126-filter-ux-standardization/spec.md b/specs/126-filter-ux-standardization/spec.md new file mode 100644 index 0000000..1659a39 --- /dev/null +++ b/specs/126-filter-ux-standardization/spec.md @@ -0,0 +1,182 @@ +# Feature Specification: Filter UX Standardization + +**Feature Branch**: `126-filter-ux-standardization` +**Created**: 2026-03-09 +**Status**: Proposed +**Input**: User description: "Spec 126 — Filter UX Standardization" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: Tier 1 and Tier 2 Filament resource list pages under `/admin`, `/admin/t/{tenant}/...`, and selected low-effort Tier 3 list pages where the current filter gap is obvious and low risk +- **Data Ownership**: Both workspace-owned and tenant-owned records are affected at the list-behavior layer only; this feature does not create new business entities or change underlying ownership +- **RBAC**: Existing workspace membership, tenant membership, plane separation, and capability gates remain authoritative; this feature standardizes list filtering behavior only within already-authorized surfaces + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Keep Investigation Context (Priority: P1) + +As an operator moving across key TenantPilot lists, I want important lists to remember my filter, search, and sort choices so I can continue an investigation without repeatedly rebuilding the same narrowed view. + +**Why this priority**: Losing list context is the most direct source of friction identified by the audit and affects core operator workflows across inventory, monitoring, backups, and governance. + +**Independent Test**: This can be tested independently by updating one Tier 1 or Tier 2 list to preserve its narrowed state across refresh and navigation, then verifying the same filtered result set returns within the same session. + +**Acceptance Scenarios**: + +1. **Given** a user narrows a Tier 1 or Tier 2 list with search, sort, and filters, **When** the user refreshes the page, **Then** the same narrowed view remains active. +2. **Given** a user narrows a Tier 1 or Tier 2 list and navigates away, **When** the user returns to that list within the same session, **Then** the previous narrowing remains in place. + +--- + +### User Story 2 - Filter Similar Lists the Same Way (Priority: P2) + +As an operator comparing similar record types, I want archived, status, and time-based filters to behave consistently across lists so I do not need to relearn filter semantics on each screen. + +**Why this priority**: Cross-product inconsistency weakens trust in the UI even when filtering is technically present. Consistent semantics produce immediate value on every affected list. + +**Independent Test**: This can be tested independently by standardizing one soft-deletable list, one status-driven list, and one time-based list, then verifying that archived visibility, status labels, and date-range behavior match the shared standard. + +**Acceptance Scenarios**: + +1. **Given** two comparable list pages that expose archived records, **When** the user opens their archive visibility filter, **Then** both pages present the same Active, All, and Archived semantics. +2. **Given** a time-based list with records across multiple dates, **When** the user narrows the list to a date window, **Then** the list shows only records in that window and clearly displays the active date constraint. +3. **Given** a status-driven list backed by a stable domain vocabulary, **When** the user opens the status filter, **Then** the available choices match the shared domain labels rather than list-local wording. + +--- + +### User Story 3 - Prevent Filter Drift (Priority: P3) + +As a platform maintainer, I want automated guard coverage around the agreed filter standard so future list changes do not quietly reintroduce inconsistency. + +**Why this priority**: The audit shows the platform drifted because conventions were only partially enforced. Lightweight automated guards keep the standard durable without introducing a filter framework. + +**Independent Test**: This can be tested independently by extending guard coverage so it fails when a required list drops persistence, omits a required archive filter, or uses a non-standard status source where the standard requires a centralized source. + +**Acceptance Scenarios**: + +1. **Given** an in-scope list drops required list-state persistence, **When** the guard suite runs, **Then** the suite fails with an actionable message. +2. **Given** a soft-deletable in-scope list omits the standard archive visibility behavior, **When** the guard suite runs, **Then** the suite fails before drift reaches production. + +### Edge Cases + +- When a list has too few records for additional filtering to provide user value, the standard allows that list to remain lightly filtered rather than forcing unnecessary controls. +- When a resource has no centralized enum or catalog yet, the rollout may retain a stable temporary option source only if replacing it would materially expand the current scope. +- When a time-based list already applies a default time window, the default must remain obvious and reversible so users do not mistake hidden records for missing data. +- When a resource uses relation-heavy or computed filters, the added filters must not weaken workspace or tenant scoping or impose disproportionate query cost. +- When a workspace-scoped monitoring list can contain tenant-bound and tenantless rows, new filters must not reveal cross-tenant values outside the user’s existing entitlement boundaries. + +### Approved Exceptions + +- No query-risk exceptions were approved for this rollout. `RestoreRunResource` required an explicit tenant-scoped base query during implementation so the standard could ship without weakening isolation. +- No transitional centralized status-source exceptions were approved. `FindingResource` and `AlertDeliveryResource` both moved to the thin shared option catalog during the rollout. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature does not introduce Microsoft Graph calls, new write behavior, queue or schedule behavior, or new operational workflows. It standardizes list filtering behavior on existing surfaces only. + +**Constitution alignment (RBAC-UX):** This feature touches both tenant/admin and workspace-scoped list surfaces, but it does not change authorization semantics. Non-membership remains deny-as-not-found, capability checks remain server-side on existing actions, and filter additions must not widen result visibility across workspace or tenant boundaries. The test plan must include positive and negative scope coverage on representative tenant-scoped and workspace-scoped lists. + +**Constitution alignment (BADGE-001):** This feature may align status and outcome filter option sources with centralized enums or catalogs, but it does not create new badge vocabularies. If a centralized status source is introduced or expanded as part of rollout, related display semantics must remain centralized and covered by regression tests. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. This feature modifies list filtering behavior only; existing list header actions, row actions, bulk actions, inspection affordances, empty-state CTAs, and detail-page actions remain resource-local and keep their current authorization and audit behavior. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** This feature directly strengthens the table portion of UX-001 by standardizing when important lists preserve state and expose core filters. It does not redesign create, edit, or view layouts, and it does not alter existing empty-state structure beyond preserving current list compliance. + +### Functional Requirements + +- **FR-001**: The system MUST define a single repo-wide filter UX standard for in-scope Filament resource lists. +- **FR-002**: The standard MUST apply mandatory list-state persistence to all Tier 1 and Tier 2 resource lists that expose filters. +- **FR-003**: Tier 1 resources for this standard are `PolicyResource`, `FindingResource`, `OperationRunResource`, `TenantResource`, and `InventoryItemResource`. +- **FR-004**: Tier 2 resources for this standard are `BackupScheduleResource`, `BackupSetResource`, `RestoreRunResource`, `PolicyVersionResource`, `ProviderConnectionResource`, `AlertDeliveryResource`, and `EntraGroupResource`. +- **FR-005**: Tier 3 resources may receive low-effort, high-value improvements, but Tier 3 work MUST NOT delay Tier 1 or Tier 2 rollout. +- **FR-006**: Every in-scope soft-deletable resource list MUST expose a consistent archive visibility filter using the shared Active, All, and Archived semantics. +- **FR-007**: Archived visibility wording MUST use “Archived” rather than resource-local alternatives such as “Trashed” or “Deleted.” +- **FR-008**: Status and outcome filters on prioritized resources MUST source their options from a centralized enum or domain catalog when such a source exists. +- **FR-009**: `FindingResource` and `AlertDeliveryResource` status filtering MUST be aligned to centralized option sources as part of the standardization pass unless a documented transitional exception is required. +- **FR-010**: Time-based Tier 1 and Tier 2 lists MUST expose a native date-range narrowing pattern when time is a primary investigation dimension. +- **FR-011**: At minimum, `FindingResource`, `AlertDeliveryResource`, `RestoreRunResource`, and `PolicyVersionResource` MUST gain date-range filtering aligned to their primary time field. +- **FR-012**: Every newly added date-range filter MUST display active filter indicators so users can immediately see the applied date window. +- **FR-013**: The rollout MUST close the highest-value missing essential filters on `RestoreRunResource`, `PolicyVersionResource`, `InventoryItemResource`, and `BaselineProfileResource`. +- **FR-014**: Essential filter additions MUST prioritize domain-appropriate dimensions such as status, outcome, policy type, platform, sync freshness, and primary time windows rather than speculative filter expansion. +- **FR-015**: Smart defaults may be used to reduce noise on lists dominated by inactive, archived, ignored, open, or recently changed records, but every default MUST be obvious and easy to clear. +- **FR-016**: The standard MUST preserve existing workspace and tenant scoping behavior, including on monitoring surfaces that operate without an active tenant in the URL. +- **FR-017**: The standard MUST avoid introducing a new filter framework, plugin dependency, or custom grouped-filter UI layer. +- **FR-018**: Any shared support extracted during implementation MUST remain thin, explicit, and limited to repeated mechanical presets. +- **FR-019**: Guard coverage MUST be extended so required Tier 1 and Tier 2 persistence cannot regress silently. +- **FR-020**: Guard coverage MUST detect missing standardized archive visibility on in-scope soft-deletable resource lists. +- **FR-021**: Guard coverage MUST detect prioritized status filters that drift away from their required centralized option source. +- **FR-022**: Functional tests for changed surfaces MUST cover filter application, filter clearing, multi-filter composition, and representative scope safety. +- **FR-023**: The rollout MUST be phaseable, with persistence and guard coverage delivered before lower-priority polish. + +## Rollout Priorities + +### Phase 1 - Persistence and Guard Coverage + +- Close the highest-value persistence gaps on `InventoryItemResource`, `PolicyVersionResource`, `RestoreRunResource`, `AlertDeliveryResource`, and `EntraGroupResource`. +- Extend table guard coverage so Tier 1 and Tier 2 persistence expectations are enforced consistently. + +### Phase 2 - Essential Filters + +- Add missing status, outcome, platform, policy type, sync freshness, and date-range filters on the highest-value Tier 1 and Tier 2 lists. +- Preserve existing query scopes and tenancy boundaries while doing so. + +### Phase 3 - Consistency and Polish + +- Align prioritized status sources to centralized enums or catalogs. +- Normalize labels on recurring dimensions such as status, outcome, archived visibility, platform, and policy type. +- Use a thin shared helper for repeated mechanical patterns such as centralized option sourcing plus archived and date-range presets. + +### Phase 4 - Optional Low-Priority Follow-ups + +- Consider low-effort Tier 3 additions only after Tier 1 and Tier 2 consistency is stable. + +## 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Finding list | `app/Filament/Resources/FindingResource.php` + `app/Filament/Resources/FindingResource/Pages/ListFindings.php` | Existing finding header actions retained | Existing record inspection affordance retained | Existing workflow row actions retained | Existing grouped bulk actions retained | Existing empty-state CTA structure retained | Existing `ViewFinding` header actions retained | Not changed by this spec | Unchanged | Filter-only change; existing workflow actions, audit behavior, and authorization remain resource-local | +| Inventory item list | `app/Filament/Resources/InventoryItemResource.php` + `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php` | Existing resource header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained where supported | Existing empty-state CTA structure retained | Existing `ViewInventoryItem` header actions retained | Not changed by this spec | Unchanged | Filter-only change | +| Policy version list | `app/Filament/Resources/PolicyVersionResource.php` + `app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php` | Existing policy-version header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained | Existing empty-state CTA structure retained | Existing `ViewPolicyVersion` header actions retained | Not changed by this spec | Unchanged | Filter-only change; existing destructive actions keep confirmation + authorization | +| Restore run list | `app/Filament/Resources/RestoreRunResource.php` + `app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php` | Existing restore-run header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained | Existing empty-state CTA structure retained | Existing `ViewRestoreRun` header actions retained | Existing create flow retained | Unchanged | Filter-only change | +| Alert delivery list | `app/Filament/Resources/AlertDeliveryResource.php` + `app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php` | Existing monitoring header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained where supported | Existing empty-state CTA structure retained | Existing `ViewAlertDelivery` header actions retained | Not changed by this spec | Unchanged | Filter-only change; workspace-context entitlement behavior remains unchanged | +| Entra group list | `app/Filament/Resources/EntraGroupResource.php` + `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php` | Existing resource header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained where supported | Existing empty-state CTA structure retained | Existing `ViewEntraGroup` header actions retained | Not changed by this spec | Unchanged | Filter-only change | +| Baseline profile list | `app/Filament/Resources/BaselineProfileResource.php` + `app/Filament/Resources/BaselineProfileResource/Pages/ListBaselineProfiles.php` | Existing baseline-profile header actions retained | Existing record inspection affordance retained | Existing row actions retained | Existing grouped bulk actions retained | Existing empty-state CTA structure retained | Existing `ViewBaselineProfile` header actions retained | Existing create/edit flows retained | Unchanged | Filter-only change | +| Operation run canonical table | `app/Filament/Resources/OperationRunResource.php` and workspace monitoring pages that reuse it | Existing canonical page actions retained | Existing record inspection affordance retained in monitoring surfaces | Existing row actions retained | Existing grouped bulk actions retained where supported | Existing empty-state CTA structure retained | No new view/create/edit change in this spec | Not applicable | Unchanged | Filter option-label alignment only; canonical workspace view remains DB-only and entitlement-safe | + +### Key Entities *(include if feature involves data)* + +- **Filter UX Tier**: The priority classification that decides whether a resource list requires mandatory persistence and essential filter coverage. +- **Filter Behavior Profile**: The combination of persistence, archive visibility, status sourcing, date-range support, defaults, labels, and active indicators expected on a given list. +- **Centralized Status Source**: The shared domain vocabulary used to keep status and outcome filter options consistent across comparable resources. +- **Filter Guard Rule**: The automated enforcement rule that protects the agreed filter standard from drifting over time. + +## Assumptions + +- The comprehensive filter audit remains an accurate baseline for the 36 current table surfaces, even if minor counts shift during implementation. +- Existing list actions, empty states, and inspection affordances remain correct unless a separate spec changes them. +- Time-based resources already expose sufficient timestamp data to support user-visible date narrowing without introducing new data structures. +- Optional Tier 3 work is limited to clearly beneficial, low-risk additions such as simple status or state filters. + +## Dependencies + +- The feature depends on the current audit inventory of filter coverage and persistence gaps remaining available as the planning baseline. +- The feature depends on existing centralized enums and catalogs where they already exist for status or outcome vocabularies. +- The feature depends on current workspace and tenant scoping rules remaining intact across affected list queries. +- The feature depends on existing table guard infrastructure being extended rather than replaced. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In the post-rollout audit, 100% of Tier 1 and Tier 2 resource lists that expose filters preserve filter, search, and sort state within the same session. +- **SC-002**: In the post-rollout audit, 100% of in-scope soft-deletable resource lists present the same Archived visibility semantics. +- **SC-003**: `FindingResource`, `AlertDeliveryResource`, `RestoreRunResource`, and `PolicyVersionResource` all expose user-visible date-range narrowing with active indicators. +- **SC-004**: The prioritized lists identified for this feature expose the required essential filters without introducing documented workspace or tenant scope regressions. +- **SC-005**: Automated guard coverage fails whenever an in-scope list drops required persistence, archive visibility, or mandated centralized status sourcing. +- **SC-006**: In manual QA, users can refresh or revisit an in-scope important list without reapplying their previously chosen narrowing during the same session. diff --git a/specs/126-filter-ux-standardization/tasks.md b/specs/126-filter-ux-standardization/tasks.md new file mode 100644 index 0000000..f2f1d34 --- /dev/null +++ b/specs/126-filter-ux-standardization/tasks.md @@ -0,0 +1,196 @@ +# Tasks: Filter UX Standardization + +**Input**: Design documents from `/specs/126-filter-ux-standardization/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/filament-filter-state.openapi.yaml`, `quickstart.md` + +**Tests**: Required. This feature changes runtime Filament table behavior, so Pest and Livewire coverage must be updated for persistence, filter behavior, guard enforcement, and scope safety. +**Operations**: No new `OperationRun` lifecycle or operational workflow work is introduced; only existing `OperationRun` table filter-label alignment and related test coverage are in scope. +**RBAC**: Authorization semantics do not change, but tasks must preserve tenant/workspace scope boundaries and include positive and negative scope regression coverage on representative lists. +**Filament UI Action Surfaces**: The feature changes list-table filtering only. The task list includes explicit work to keep the spec’s UI Action Matrix accurate for the changed surfaces while preserving existing action surfaces. +**Filament UI UX-001**: The feature affects list-table filtering and persistence only. Empty-state, create/edit, and view layouts remain unchanged unless a changed surface requires an explicit note. +**Badges**: Status label sourcing must continue to rely on centralized domain vocabularies; no ad-hoc status mapping may be introduced. + +**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently. + +## Phase 1: Setup (Shared Implementation Alignment) + +**Purpose**: Lock the concrete rollout surface inventory and shared implementation targets before code changes start. + +- [X] T001 Review the concrete rollout targets and current list-page entry points in `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/InventoryItemResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/AlertDeliveryResource.php`, `app/Filament/Resources/EntraGroupResource.php`, `app/Filament/Resources/BaselineProfileResource.php`, and `app/Filament/Resources/OperationRunResource.php` +- [X] T002 Confirm centralized option-source inputs in `app/Models/Finding.php`, `app/Models/AlertDelivery.php`, `app/Support/Badges/BadgeCatalog.php`, and `app/Support/Baselines/BaselineProfileStatus.php` before implementing shared filter helpers + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared support and enforcement that all user stories depend on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T003 Create thin shared filter support in `app/Support/Filament/FilterOptionCatalog.php` and `app/Support/Filament/FilterPresets.php` for centralized option sourcing plus native archived/date-range presets +- [X] T004 [P] Extend reusable Tier 1–2 surface inventories and helper assertions in `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` to prepare for persistence, archived-filter, and centralized-status guard coverage +- [X] T005 [P] Extend the shared Livewire persistence harness in `tests/Feature/Filament/TableStatePersistenceTest.php` so additional resource list page components can be covered without duplicating setup logic + +**Checkpoint**: Shared filter support and reusable test scaffolding are ready. + +--- + +## Phase 3: User Story 1 - Keep Investigation Context (Priority: P1) 🎯 MVP + +**Goal**: Ensure important Tier 1–2 resource lists preserve filter, search, and sort state across refresh and navigation. + +**Independent Test**: Apply search, sort, and filters on each newly covered in-scope list, remount the component, and confirm the same state persists without widening workspace or tenant scope. + +### Tests for User Story 1 + +- [X] T006 [P] [US1] Extend persistence coverage in `tests/Feature/Filament/TableStatePersistenceTest.php` for `ListInventoryItems`, `ListPolicyVersions`, `ListRestoreRuns`, `ListAlertDeliveries`, and `ListEntraGroups` +- [X] T007 [P] [US1] Add representative scope-safe persistence regression coverage in `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` and `tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php` + +### Implementation for User Story 1 + +- [X] T008 [P] [US1] Add `persistFiltersInSession()`, `persistSearchInSession()`, and `persistSortInSession()` to `app/Filament/Resources/InventoryItemResource.php` and `app/Filament/Resources/EntraGroupResource.php` +- [X] T009 [P] [US1] Add `persistFiltersInSession()`, `persistSearchInSession()`, and `persistSortInSession()` to `app/Filament/Resources/PolicyVersionResource.php` and `app/Filament/Resources/RestoreRunResource.php` +- [X] T010 [US1] Add `persistFiltersInSession()`, `persistSearchInSession()`, and `persistSortInSession()` to `app/Filament/Resources/AlertDeliveryResource.php` while preserving workspace-context entitlement safety +- [X] T011 [US1] Expand persistence enforcement in `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for all Tier 1–2 filtered resource lists + +**Checkpoint**: Important resource lists retain investigation context across remounts and guard enforcement covers the expanded persistence set. + +--- + +## Phase 4: User Story 2 - Filter Similar Lists the Same Way (Priority: P2) + +**Goal**: Standardize archived visibility, centralized status sourcing, and missing date-range or essential filters across the highest-value Tier 1–2 lists. + +**Independent Test**: Verify that changed lists expose the expected filters, that filters apply and clear correctly, and that date-range indicators and archived semantics are consistent across comparable surfaces. + +### Tests for User Story 2 + +- [X] T012 [P] [US2] Add Findings filter tests covering filter application, clearing/reset, multi-filter composition, default behavior, date-range behavior, and representative workspace/tenant scope safety in `tests/Feature/Findings/FindingsListFiltersTest.php` and `tests/Feature/Findings/FindingsListDefaultsTest.php` +- [X] T013 [P] [US2] Add AlertDelivery filter tests covering filter application, clearing/reset, multi-filter composition, default behavior, date-range behavior, and representative workspace/tenant scope safety in `tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php` and `tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php` +- [X] T014 [P] [US2] Add `OperationRunResource` filter behavior tests covering filter application, clearing/reset, multi-filter composition, and representative workspace/tenant scope safety in `tests/Feature/Filament/OperationRunListFiltersTest.php` +- [X] T015 [P] [US2] Add filter-application, clearing/reset, and composition coverage in `tests/Feature/Filament/PolicyVersionListFiltersTest.php` and `tests/Feature/Filament/RestoreRunListFiltersTest.php` +- [X] T016 [P] [US2] Add filter-application, clearing/reset, and scope-safe behavior coverage in `tests/Feature/Filament/InventoryItemListFiltersTest.php` and `tests/Feature/Filament/BaselineProfileListFiltersTest.php` + +### Implementation for User Story 2 + +- [X] T017 [US2] Wire `app/Support/Filament/FilterOptionCatalog.php` into `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/AlertDeliveryResource.php` for centralized status option sourcing +- [X] T018 [US2] Add native date-range filters with `indicateUsing()` in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/AlertDeliveryResource.php` using `app/Support/Filament/FilterPresets.php` +- [X] T019 [US2] Add status, outcome, date-range, and standard archived filtering in `app/Filament/Resources/RestoreRunResource.php` +- [X] T020 [US2] Add policy type, platform, `captured_at` date-range, and standard archived filtering in `app/Filament/Resources/PolicyVersionResource.php` +- [X] T021 [US2] Add platform and sync-freshness filtering in `app/Filament/Resources/InventoryItemResource.php` and a status filter in `app/Filament/Resources/BaselineProfileResource.php` +- [X] T022 [US2] Update the operation-type filter options in `app/Filament/Resources/OperationRunResource.php` to use `OperationCatalog` labels consistently with the filter standard + +**Checkpoint**: The highest-value Tier 1–2 lists share predictable archived semantics, date-range behavior, and centralized status labels. + +--- + +## Phase 5: User Story 3 - Prevent Filter Drift (Priority: P3) + +**Goal**: Keep the new filter standard enforceable so future list changes cannot silently regress persistence, archived filtering, or centralized status sourcing. + +**Independent Test**: Introduce a deliberate regression locally, run the targeted guard suite, and confirm it fails with actionable output pointing to the violating resource. + +### Tests for User Story 3 + +- [X] T023 [P] [US3] Extend `tests/Feature/Guards/FilamentTableStandardsGuardTest.php` to assert standard archived filter usage and prioritized centralized status sourcing on in-scope resources +- [X] T024 [P] [US3] Add composed-filter scope regression coverage in `tests/Feature/ProviderConnections/TenantFilterOverrideTest.php` and `tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php` + +### Implementation for User Story 3 + +- [X] T025 [US3] Adopt `app/Support/Filament/FilterPresets.php` in `app/Filament/Resources/PolicyVersionResource.php` and `app/Filament/Resources/RestoreRunResource.php` so repeated archived/date-range patterns stay mechanically consistent +- [X] T026 [US3] Record any approved query-risk or transitional status-source exceptions in `specs/126-filter-ux-standardization/spec.md`, `specs/126-filter-ux-standardization/plan.md`, and `specs/126-filter-ux-standardization/research.md` + +**Checkpoint**: The filter standard is guarded in CI and any remaining exceptions are explicit rather than accidental. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Final verification, formatting, and manual validation across stories. + +- [X] T027 [P] Run focused Pest coverage for `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `tests/Feature/Filament/TableStatePersistenceTest.php`, `tests/Feature/Findings/FindingsListFiltersTest.php`, `tests/Feature/Findings/FindingsListDefaultsTest.php`, `tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php`, `tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php`, `tests/Feature/Filament/OperationRunListFiltersTest.php`, `tests/Feature/Filament/PolicyVersionListFiltersTest.php`, `tests/Feature/Filament/RestoreRunListFiltersTest.php`, `tests/Feature/Filament/InventoryItemListFiltersTest.php`, and `tests/Feature/Filament/BaselineProfileListFiltersTest.php` +- [X] T028 Run formatting on changed files with `vendor/bin/sail bin pint --dirty --format agent` +- [ ] T029 [P] Validate the manual QA scenarios in `specs/126-filter-ux-standardization/quickstart.md` against the changed resource lists and record any follow-up exceptions in `specs/126-filter-ux-standardization/research.md` + +--- + +## 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)**: Starts after Foundational completion; represents the MVP slice. +- **User Story 2 (Phase 4)**: Starts after Foundational completion; can overlap with US1 if staffing allows, but is lower priority. +- **User Story 3 (Phase 5)**: Depends on the shared support from Foundational and should follow the main persistence and filter implementations so guards reflect the final standardized state. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: Depends only on Foundational work. +- **User Story 2 (P2)**: Depends on Foundational work and reuses the shared support from `app/Support/Filament/FilterOptionCatalog.php` and `app/Support/Filament/FilterPresets.php`. +- **User Story 3 (P3)**: Depends on Foundational work and should be completed after the main US1/US2 resource changes so static guard coverage matches reality. + +### Within Each User Story + +- Write or extend tests first and confirm they fail before implementing behavior changes. +- Shared support before resource-table rewrites. +- Resource-table changes before guard finalization. +- Functional verification before polish. + +### Parallel Opportunities + +- `T004` and `T005` can run in parallel once `T003` is scoped. +- In US1, `T006` and `T007` can run in parallel, as can `T008` and `T009`. +- In US2, `T012` through `T016` can run in parallel, followed by surface-specific implementation tasks split across resources. +- In US3, `T023` and `T024` can run in parallel. +- In Polish, `T027` and `T029` can run in parallel after implementation is complete. + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch persistence tests together: +Task: "Extend tests/Feature/Filament/TableStatePersistenceTest.php for the five new resource lists" +Task: "Add scope-safe persistence regression coverage in tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php and tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php" + +# Launch resource persistence updates together: +Task: "Add the persistence trio to app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/EntraGroupResource.php" +Task: "Add the persistence trio to app/Filament/Resources/PolicyVersionResource.php and app/Filament/Resources/RestoreRunResource.php" +``` + +--- + +## Parallel Example: User Story 2 + +```bash +# Launch focused filter tests together: +Task: "Extend findings filter tests in tests/Feature/Findings/FindingsListFiltersTest.php and tests/Feature/Findings/FindingsListDefaultsTest.php" +Task: "Extend alert delivery filter tests in tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php and tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php" +Task: "Add policy version and restore run list filter tests in tests/Feature/Filament/PolicyVersionListFiltersTest.php and tests/Feature/Filament/RestoreRunListFiltersTest.php" +Task: "Add inventory item and baseline profile list filter tests in tests/Feature/Filament/InventoryItemListFiltersTest.php and tests/Feature/Filament/BaselineProfileListFiltersTest.php" + +# Launch resource implementations by surface group: +Task: "Add finding and alert delivery centralized status/date-range filters in app/Filament/Resources/FindingResource.php and app/Filament/Resources/AlertDeliveryResource.php" +Task: "Add restore run and policy version essential filters in app/Filament/Resources/RestoreRunResource.php and app/Filament/Resources/PolicyVersionResource.php" +Task: "Add inventory item and baseline profile essential filters in app/Filament/Resources/InventoryItemResource.php and app/Filament/Resources/BaselineProfileResource.php" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate persistence behavior and expanded persistence guards. + +### Incremental Delivery + +1. Deliver US1 first to close the most painful operator gap: lost list context. +2. Deliver US2 next to standardize the highest-value filters and date-range behavior. +3. Deliver US3 last to lock the standard with stronger guard coverage and documented exceptions. +4. Finish with focused tests, formatting, and manual QA. diff --git a/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php b/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php index ee3344f..04b2808 100644 --- a/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php +++ b/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php @@ -12,6 +12,13 @@ uses(RefreshDatabase::class); +function alertDeliveryFilterIndicatorLabels($component): array +{ + return collect($component->instance()->getTable()->getFilterIndicators()) + ->map(fn ($indicator): string => (string) $indicator->getLabel()) + ->all(); +} + // --------------------------------------------------------------------------- // T030 — Deliveries viewer filter coverage (event_type + alert_destination_id) // --------------------------------------------------------------------------- @@ -116,3 +123,104 @@ Livewire::test(ListAlertDeliveries::class) ->assertCanSeeTableRecords([$testDelivery]); }); + +it('composes status and created date filters for alert deliveries', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $matching = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + 'created_at' => now()->subDay(), + ]); + + $wrongStatus = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + 'created_at' => now()->subDay(), + ]); + + $outsideWindow = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + 'created_at' => now()->subDays(10), + ]); + + $component = Livewire::test(ListAlertDeliveries::class) + ->filterTable('status', AlertDelivery::STATUS_SENT) + ->set('tableFilters.created_at.from', now()->subDays(2)->toDateString()) + ->set('tableFilters.created_at.until', now()->toDateString()) + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$wrongStatus, $outsideWindow]); + + expect(alertDeliveryFilterIndicatorLabels($component)) + ->toContain('Created from '.now()->subDays(2)->toFormattedDateString()) + ->toContain('Created until '.now()->toFormattedDateString()); +}); + +it('clears alert delivery filters back to the full entitled list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $queued = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_QUEUED, + 'created_at' => now()->subDays(7), + ]); + + $sent = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenant->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + 'created_at' => now()->subDay(), + ]); + + $component = Livewire::test(ListAlertDeliveries::class) + ->filterTable('status', AlertDelivery::STATUS_SENT) + ->set('tableFilters.created_at.from', now()->subDays(2)->toDateString()) + ->set('tableFilters.created_at.until', now()->toDateString()) + ->assertCanSeeTableRecords([$sent]) + ->assertCanNotSeeTableRecords([$queued]); + + $component + ->set('tableFilters.status.value', null) + ->set('tableFilters.created_at.from', null) + ->set('tableFilters.created_at.until', null) + ->assertCanSeeTableRecords([$queued, $sent]); +}); diff --git a/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php b/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php index a7bdcdf..029d68b 100644 --- a/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php +++ b/tests/Feature/Filament/Alerts/AlertDeliveryViewerTest.php @@ -14,6 +14,7 @@ use App\Models\WorkspaceMembership; use App\Services\Auth\WorkspaceCapabilityResolver; use Filament\Actions\Action; +use Filament\Facades\Filament; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; @@ -180,3 +181,53 @@ function getAlertDeliveryHeaderAction(Testable $component, string $name): ?Actio ->get(AlertDeliveryResource::getUrl(panel: 'admin')) ->assertForbidden(); }); + +it('keeps persisted alert delivery filters inside the active tenant scope', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $workspaceId = (int) $tenantA->workspace_id; + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $tenantADelivery = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenantA->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + $tenantBDelivery = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => (int) $tenantB->getKey(), + 'alert_rule_id' => (int) $rule->getKey(), + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + $this->actingAs($user); + Filament::setTenant($tenantA, true); + + Livewire::test(ListAlertDeliveries::class) + ->filterTable('status', AlertDelivery::STATUS_SENT) + ->assertCanSeeTableRecords([$tenantADelivery]) + ->assertCanNotSeeTableRecords([$tenantBDelivery]); + + Livewire::test(ListAlertDeliveries::class) + ->assertSet('tableFilters.status.value', AlertDelivery::STATUS_SENT) + ->assertCanSeeTableRecords([$tenantADelivery]) + ->assertCanNotSeeTableRecords([$tenantBDelivery]); +}); diff --git a/tests/Feature/Filament/BaselineProfileListFiltersTest.php b/tests/Feature/Filament/BaselineProfileListFiltersTest.php new file mode 100644 index 0000000..c62aef2 --- /dev/null +++ b/tests/Feature/Filament/BaselineProfileListFiltersTest.php @@ -0,0 +1,101 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $draft = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => BaselineProfileStatus::Draft->value, + ]); + + $otherWorkspace = Workspace::factory()->create(); + $otherWorkspaceProfile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + ]); + + Livewire::actingAs($user) + ->test(ListBaselineProfiles::class) + ->filterTable('status', BaselineProfileStatus::Active->value) + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$draft, $otherWorkspaceProfile]); +}); + +it('clears the baseline profile status filter back to the workspace-owned list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $active = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $draft = BaselineProfile::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'status' => BaselineProfileStatus::Draft->value, + ]); + + $component = Livewire::actingAs($user) + ->test(ListBaselineProfiles::class) + ->filterTable('status', BaselineProfileStatus::Draft->value) + ->assertCanSeeTableRecords([$draft]) + ->assertCanNotSeeTableRecords([$active]); + + $component + ->set('tableFilters.status.value', null) + ->assertCanSeeTableRecords([$active, $draft]); +}); + +it('shows assigned tenant counts on the baseline profile list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $assignedProfile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $unassignedProfile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $firstAssignedTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + $secondAssignedTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + ]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $firstAssignedTenant->getKey(), + 'baseline_profile_id' => (int) $assignedProfile->getKey(), + ]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $secondAssignedTenant->getKey(), + 'baseline_profile_id' => (int) $assignedProfile->getKey(), + ]); + + Livewire::actingAs($user) + ->test(ListBaselineProfiles::class) + ->assertTableColumnExists('tenant_assignments_count', function (TextColumn $column): bool { + return $column->getLabel() === 'Assigned tenants' + && (int) $column->getState() === 2; + }, $assignedProfile) + ->assertTableColumnExists('tenant_assignments_count', function (TextColumn $column): bool { + return (int) $column->getState() === 0; + }, $unassignedProfile); +}); diff --git a/tests/Feature/Filament/BaselineTenantAssignmentsRelationManagerTest.php b/tests/Feature/Filament/BaselineTenantAssignmentsRelationManagerTest.php new file mode 100644 index 0000000..a8635aa --- /dev/null +++ b/tests/Feature/Filament/BaselineTenantAssignmentsRelationManagerTest.php @@ -0,0 +1,69 @@ +active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Current Baseline', + ]); + + $otherProfile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Device Hardening', + ]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'baseline_profile_id' => (int) $currentProfile->getKey(), + ]); + + $assignedTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Already Assigned Tenant', + ]); + + $availableTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Available Tenant', + ]); + + BaselineTenantAssignment::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $assignedTenant->getKey(), + 'baseline_profile_id' => (int) $otherProfile->getKey(), + ]); + + Livewire::actingAs($user) + ->test(BaselineTenantAssignmentsRelationManager::class, [ + 'ownerRecord' => $currentProfile, + 'pageClass' => EditBaselineProfile::class, + ]) + ->mountTableAction('assign') + ->assertFormFieldExists('tenant_id', function (Select $field) use ($assignedTenant, $availableTenant, $otherProfile): bool { + $options = $field->getOptions(); + + $assignedTenantKey = (int) $assignedTenant->getKey(); + $availableTenantKey = (int) $availableTenant->getKey(); + + $assignedLabel = $options[$assignedTenantKey] ?? $options[(string) $assignedTenantKey] ?? null; + $availableLabel = $options[$availableTenantKey] ?? $options[(string) $availableTenantKey] ?? null; + + return $field->isSearchable() + && $assignedLabel === 'Already Assigned Tenant (assigned to baseline: '.$otherProfile->name.')' + && $field->isOptionDisabled($assignedTenantKey, $assignedLabel) + && $availableLabel === 'Available Tenant' + && ! $field->isOptionDisabled($availableTenantKey, $availableLabel); + }); +}); diff --git a/tests/Feature/Filament/InventoryItemListFiltersTest.php b/tests/Feature/Filament/InventoryItemListFiltersTest.php new file mode 100644 index 0000000..2cc40f0 --- /dev/null +++ b/tests/Feature/Filament/InventoryItemListFiltersTest.php @@ -0,0 +1,83 @@ +create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $matching = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'last_seen_at' => now(), + ]); + + $stale = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'last_seen_at' => now()->subDays(3), + ]); + + $otherTenant = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'last_seen_at' => now(), + ]); + + $this->actingAs($user); + $tenantA->makeCurrent(); + Filament::setTenant($tenantA, true); + + Livewire::test(ListInventoryItems::class) + ->filterTable('policy_type', 'deviceConfiguration') + ->filterTable('platform', 'windows') + ->filterTable('stale', '0') + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$stale, $otherTenant]); +}); + +it('clears inventory item filters back to the tenant-scoped list', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $fresh = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'platform' => 'windows', + 'last_seen_at' => now(), + ]); + + $stale = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'platform' => 'windows', + 'last_seen_at' => now()->subDays(3), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListInventoryItems::class) + ->filterTable('platform', 'windows') + ->filterTable('stale', '1') + ->assertCanSeeTableRecords([$stale]) + ->assertCanNotSeeTableRecords([$fresh]); + + $component + ->set('tableFilters.platform.value', null) + ->set('tableFilters.stale.value', null) + ->assertCanSeeTableRecords([$fresh, $stale]); +}); diff --git a/tests/Feature/Filament/OperationRunListFiltersTest.php b/tests/Feature/Filament/OperationRunListFiltersTest.php new file mode 100644 index 0000000..7a49abf --- /dev/null +++ b/tests/Feature/Filament/OperationRunListFiltersTest.php @@ -0,0 +1,124 @@ +instance()->getTable()->getFilterIndicators()) + ->map(fn ($indicator): string => (string) $indicator->getLabel()) + ->all(); +} + +it('maps the operation type filter to operation catalog labels', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'policy.sync', + ]); + + Filament::setTenant($tenant, true); + + $component = Livewire::actingAs($user) + ->test(Operations::class) + ->assertTableFilterExists('type'); + + /** @var SelectFilter|null $filter */ + $filter = $component->instance()->getTable()->getFilter('type'); + + expect($filter)->not->toBeNull(); + expect($filter?->getOptions()['policy.sync'] ?? null)->toBe('Policy sync'); +}); + +it('filters operations by type and outcome without leaking runs from other tenants', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $matching = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'workspace_id' => (int) $tenantA->workspace_id, + 'type' => 'policy.sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + ]); + + $wrongType = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'workspace_id' => (int) $tenantA->workspace_id, + 'type' => 'inventory_sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + ]); + + $otherTenant = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'workspace_id' => (int) $tenantB->workspace_id, + 'type' => 'policy.sync', + 'status' => OperationRunStatus::Completed->value, + 'outcome' => OperationRunOutcome::Succeeded->value, + ]); + + Filament::setTenant($tenantA, true); + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]); + session([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id]); + + Livewire::actingAs($user) + ->test(Operations::class) + ->filterTable('type', 'policy.sync') + ->filterTable('outcome', OperationRunOutcome::Succeeded->value) + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$wrongType, $otherTenant]); +}); + +it('filters operations by created date range and can clear the range', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $recent = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'created_at' => now()->subDay(), + ]); + + $old = OperationRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'workspace_id' => (int) $tenant->workspace_id, + 'created_at' => now()->subDays(45), + ]); + + Filament::setTenant($tenant, true); + $this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]); + + $component = Livewire::actingAs($user) + ->test(Operations::class) + ->set('tableFilters.created_at.from', now()->subDays(2)->toDateString()) + ->set('tableFilters.created_at.until', now()->toDateString()) + ->assertCanSeeTableRecords([$recent]) + ->assertCanNotSeeTableRecords([$old]); + + expect(operationRunFilterIndicatorLabels($component)) + ->toContain('Created from '.now()->subDays(2)->toFormattedDateString()) + ->toContain('Created until '.now()->toFormattedDateString()); + + $component + ->set('tableFilters.created_at.from', null) + ->set('tableFilters.created_at.until', null) + ->assertCanSeeTableRecords([$recent, $old]); +}); diff --git a/tests/Feature/Filament/PolicyVersionListFiltersTest.php b/tests/Feature/Filament/PolicyVersionListFiltersTest.php new file mode 100644 index 0000000..b74bd80 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionListFiltersTest.php @@ -0,0 +1,176 @@ +instance()->getTable()->getFilterIndicators()) + ->map(fn ($indicator): string => (string) $indicator->getLabel()) + ->all(); +} + +it('filters policy versions by type and platform without leaking other tenants', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $policyA = Policy::query()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'external_id' => 'policy-a', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + ]); + + $policyB = Policy::query()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'external_id' => 'policy-b', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy B', + 'platform' => 'windows', + ]); + + $matching = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'policy_id' => (int) $policyA->getKey(), + 'version_number' => 1, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + ]); + + $wrongPlatform = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'policy_id' => (int) $policyA->getKey(), + 'version_number' => 2, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'android', + ]); + + $otherTenant = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'policy_id' => (int) $policyB->getKey(), + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + ]); + + $this->actingAs($user); + $tenantA->makeCurrent(); + Filament::setTenant($tenantA, true); + + Livewire::test(ListPolicyVersions::class) + ->filterTable('policy_type', 'deviceConfiguration') + ->filterTable('platform', 'windows') + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$wrongPlatform, $otherTenant]); +}); + +it('filters policy versions by captured date range and archived visibility', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-date', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy Date', + 'platform' => 'windows', + ]); + + $recentActive = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 1, + 'captured_at' => now()->subDay(), + ]); + + $recentArchived = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 2, + 'captured_at' => now()->subDay(), + 'deleted_at' => now(), + ]); + + $oldActive = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 3, + 'captured_at' => now()->subDays(10), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListPolicyVersions::class) + ->set('tableFilters.captured_at.from', now()->subDays(2)->toDateString()) + ->set('tableFilters.captured_at.until', now()->toDateString()) + ->filterTable('trashed', true) + ->assertCanSeeTableRecords([$recentActive, $recentArchived]) + ->assertCanNotSeeTableRecords([$oldActive]); + + expect(policyVersionFilterIndicatorLabels($component)) + ->toContain('Captured from '.now()->subDays(2)->toFormattedDateString()) + ->toContain('Captured until '.now()->toFormattedDateString()); + + $component + ->filterTable('trashed', false) + ->assertCanSeeTableRecords([$recentArchived]) + ->assertCanNotSeeTableRecords([$recentActive, $oldActive]); +}); + +it('clears policy version filters back to active records', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $policy = Policy::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => 'policy-clear', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy Clear', + 'platform' => 'windows', + ]); + + $active = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 1, + 'platform' => 'windows', + 'captured_at' => now()->subDay(), + ]); + + $archived = PolicyVersion::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 2, + 'platform' => 'windows', + 'captured_at' => now()->subDay(), + 'deleted_at' => now(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListPolicyVersions::class) + ->filterTable('platform', 'windows') + ->filterTable('trashed', false) + ->assertCanSeeTableRecords([$archived]) + ->assertCanNotSeeTableRecords([$active]); + + $component + ->set('tableFilters.platform.value', null) + ->filterTable('trashed', null) + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$archived]); +}); diff --git a/tests/Feature/Filament/RestoreRunListFiltersTest.php b/tests/Feature/Filament/RestoreRunListFiltersTest.php new file mode 100644 index 0000000..389315b --- /dev/null +++ b/tests/Feature/Filament/RestoreRunListFiltersTest.php @@ -0,0 +1,153 @@ +instance()->getTable()->getFilterIndicators()) + ->map(fn ($indicator): string => (string) $indicator->getLabel()) + ->all(); +} + +it('filters restore runs by status and derived outcome without leaking other tenants', function (): void { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + $backupSetA = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + ]); + + $backupSetB = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + ]); + + $matching = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'backup_set_id' => (int) $backupSetA->getKey(), + 'status' => RestoreRunStatus::Completed->value, + 'started_at' => now()->subDay(), + ]); + + $partial = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'backup_set_id' => (int) $backupSetA->getKey(), + 'status' => RestoreRunStatus::Partial->value, + 'started_at' => now()->subDay(), + ]); + + $otherTenant = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'backup_set_id' => (int) $backupSetB->getKey(), + 'status' => RestoreRunStatus::Completed->value, + 'started_at' => now()->subDay(), + ]); + + $this->actingAs($user); + $tenantA->makeCurrent(); + Filament::setTenant($tenantA, true); + + Livewire::test(ListRestoreRuns::class) + ->filterTable('status', RestoreRunStatus::Completed->value) + ->filterTable('outcome', 'succeeded') + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$partial, $otherTenant]); +}); + +it('filters restore runs by started date range and archived visibility', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $recentActive = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => RestoreRunStatus::Completed->value, + 'started_at' => now()->subDay(), + ]); + + $recentArchived = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => RestoreRunStatus::Failed->value, + 'started_at' => now()->subDay(), + 'deleted_at' => now(), + ]); + + $oldActive = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => RestoreRunStatus::Completed->value, + 'started_at' => now()->subDays(10), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListRestoreRuns::class) + ->set('tableFilters.started_at.from', now()->subDays(2)->toDateString()) + ->set('tableFilters.started_at.until', now()->toDateString()) + ->filterTable('trashed', true) + ->assertCanSeeTableRecords([$recentActive, $recentArchived]) + ->assertCanNotSeeTableRecords([$oldActive]); + + expect(restoreRunFilterIndicatorLabels($component)) + ->toContain('Started from '.now()->subDays(2)->toFormattedDateString()) + ->toContain('Started until '.now()->toFormattedDateString()); + + $component + ->filterTable('trashed', false) + ->assertCanSeeTableRecords([$recentArchived]) + ->assertCanNotSeeTableRecords([$recentActive, $oldActive]); +}); + +it('clears restore run filters back to active records', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + ]); + + $active = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => RestoreRunStatus::Completed->value, + ]); + + $archived = RestoreRun::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_set_id' => (int) $backupSet->getKey(), + 'status' => RestoreRunStatus::Failed->value, + 'deleted_at' => now(), + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListRestoreRuns::class) + ->filterTable('status', RestoreRunStatus::Failed->value) + ->filterTable('trashed', false) + ->assertCanSeeTableRecords([$archived]) + ->assertCanNotSeeTableRecords([$active]); + + $component + ->set('tableFilters.status.value', null) + ->filterTable('trashed', null) + ->assertCanSeeTableRecords([$active]) + ->assertCanNotSeeTableRecords([$archived]); +}); diff --git a/tests/Feature/Filament/TableStatePersistenceTest.php b/tests/Feature/Filament/TableStatePersistenceTest.php index e20289d..938f393 100644 --- a/tests/Feature/Filament/TableStatePersistenceTest.php +++ b/tests/Feature/Filament/TableStatePersistenceTest.php @@ -3,11 +3,16 @@ declare(strict_types=1); use App\Filament\Pages\Monitoring\Operations; +use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries; use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules; use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets; +use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups; use App\Filament\Resources\FindingResource\Pages\ListFindings; +use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Filament\Resources\PolicyResource\Pages\ListPolicies; +use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions; use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; +use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns; use App\Filament\Resources\TenantResource\Pages\ListTenants; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -151,6 +156,95 @@ function spec125AssertPersistedTableState( ); }); +it('persists inventory item 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( + ListInventoryItems::class, + [], + 'Policy', + 'display_name', + 'asc', + 'tableFilters.platform.value', + 'windows', + ); +}); + +it('persists policy version 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( + ListPolicyVersions::class, + [], + 'Policy', + 'captured_at', + 'asc', + 'tableFilters.platform.value', + 'windows', + ); +}); + +it('persists restore run 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( + ListRestoreRuns::class, + [], + 'Restore', + 'started_at', + 'asc', + 'tableFilters.status.value', + 'completed', + ); +}); + +it('persists alert delivery list search, sort, and filter state across remounts', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + spec125AssertPersistedTableState( + ListAlertDeliveries::class, + [], + 'alert', + 'created_at', + 'asc', + 'tableFilters.status.value', + 'sent', + ); +}); + +it('persists Entra group 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( + ListEntraGroups::class, + [], + 'Group', + 'display_name', + 'desc', + 'tableFilters.group_type.value', + 'security', + ); +}); + it('persists monitoring operations search, sort, and filter state across remounts', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); diff --git a/tests/Feature/Findings/FindingsListDefaultsTest.php b/tests/Feature/Findings/FindingsListDefaultsTest.php index a3ec1ea..9fd8661 100644 --- a/tests/Feature/Findings/FindingsListDefaultsTest.php +++ b/tests/Feature/Findings/FindingsListDefaultsTest.php @@ -10,6 +10,13 @@ uses(RefreshDatabase::class); +function findingsDefaultIndicatorLabels($component): array +{ + return collect($component->instance()->getTable()->getFilterIndicators()) + ->map(fn ($indicator): string => (string) $indicator->getLabel()) + ->all(); +} + it('defaults to open findings across all finding types', function (): void { [$user, $tenant] = createUserWithTenant(role: 'manager'); $this->actingAs($user); @@ -70,3 +77,18 @@ expect($table->getColumn('scope_key')?->isToggledHiddenByDefault())->toBeTrue(); expect(count($table->getVisibleColumns()))->toBeLessThanOrEqual(7); }); + +it('defines created date-range narrowing with active indicators on the findings table', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $component = Livewire::test(ListFindings::class) + ->assertTableFilterExists('created_at') + ->set('tableFilters.created_at.from', now()->subDay()->toDateString()) + ->set('tableFilters.created_at.until', now()->toDateString()); + + expect(findingsDefaultIndicatorLabels($component)) + ->toContain('Created from '.now()->subDay()->toFormattedDateString()) + ->toContain('Created until '.now()->toFormattedDateString()); +}); diff --git a/tests/Feature/Findings/FindingsListFiltersTest.php b/tests/Feature/Findings/FindingsListFiltersTest.php index 343fdbe..2b1318c 100644 --- a/tests/Feature/Findings/FindingsListFiltersTest.php +++ b/tests/Feature/Findings/FindingsListFiltersTest.php @@ -11,6 +11,13 @@ uses(RefreshDatabase::class); +function findingFilterIndicatorLabels($component): array +{ + return collect($component->instance()->getTable()->getFilterIndicators()) + ->map(fn ($indicator): string => (string) $indicator->getLabel()) + ->all(); +} + it('filters findings by overdue quick filter using open statuses only', function (): void { [$user, $tenant] = createUserWithTenant(role: 'manager'); $this->actingAs($user); @@ -113,3 +120,71 @@ ->assertSet('tableSort', 'created_at:asc') ->assertSet('tableFilters.status.value', Finding::STATUS_NEW); }); + +it('composes status and created date filters with active indicators', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $matching = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'created_at' => now()->subDay(), + ]); + + $wrongStatus = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'created_at' => now()->subDay(), + ]); + + $outsideWindow = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'created_at' => now()->subDays(10), + ]); + + $component = Livewire::test(ListFindings::class) + ->filterTable('status', Finding::STATUS_NEW) + ->set('tableFilters.created_at.from', now()->subDays(2)->toDateString()) + ->set('tableFilters.created_at.until', now()->toDateString()) + ->assertCanSeeTableRecords([$matching]) + ->assertCanNotSeeTableRecords([$wrongStatus, $outsideWindow]); + + expect(findingFilterIndicatorLabels($component)) + ->toContain('Created from '.now()->subDays(2)->toFormattedDateString()) + ->toContain('Created until '.now()->toFormattedDateString()); +}); + +it('clears findings filters back to the default open view', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $openFinding = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_NEW, + 'created_at' => now()->subDays(7), + ]); + + $recentResolved = Finding::factory()->for($tenant)->create([ + 'status' => Finding::STATUS_RESOLVED, + 'created_at' => now()->subDay(), + ]); + + $component = Livewire::test(ListFindings::class) + ->set('tableFilters.open.isActive', false) + ->filterTable('status', Finding::STATUS_RESOLVED) + ->set('tableFilters.created_at.from', now()->subDays(2)->toDateString()) + ->set('tableFilters.created_at.until', now()->toDateString()) + ->assertCanSeeTableRecords([$recentResolved]) + ->assertCanNotSeeTableRecords([$openFinding]); + + $component + ->removeTableFilter('status') + ->removeTableFilter('created_at', 'from') + ->removeTableFilter('created_at', 'until') + ->assertSet('tableFilters.status.value', null) + ->assertSet('tableFilters.created_at.from', null) + ->assertSet('tableFilters.created_at.until', null); + + expect(findingFilterIndicatorLabels($component)) + ->not->toContain('Created from '.now()->subDays(2)->toFormattedDateString()) + ->not->toContain('Created until '.now()->toFormattedDateString()); +}); diff --git a/tests/Feature/Guards/FilamentTableStandardsGuardTest.php b/tests/Feature/Guards/FilamentTableStandardsGuardTest.php index 397e352..c477210 100644 --- a/tests/Feature/Guards/FilamentTableStandardsGuardTest.php +++ b/tests/Feature/Guards/FilamentTableStandardsGuardTest.php @@ -127,6 +127,11 @@ 'app/Filament/Resources/BackupScheduleResource.php', 'app/Filament/Resources/ProviderConnectionResource.php', 'app/Filament/Resources/FindingResource.php', + 'app/Filament/Resources/InventoryItemResource.php', + 'app/Filament/Resources/PolicyVersionResource.php', + 'app/Filament/Resources/RestoreRunResource.php', + 'app/Filament/Resources/AlertDeliveryResource.php', + 'app/Filament/Resources/EntraGroupResource.php', 'app/Filament/Resources/OperationRunResource.php', ]; @@ -151,6 +156,68 @@ expect($missing)->toBeEmpty('Missing persistence declarations: '.implode(', ', $missing)); }); +it('uses shared archived and date-range presets on the repeated soft-delete filter surfaces', function (): void { + $patternByPath = [ + 'app/Filament/Resources/PolicyVersionResource.php' => [ + 'FilterPresets::dateRange(', + 'FilterPresets::archived()', + ], + 'app/Filament/Resources/RestoreRunResource.php' => [ + 'FilterPresets::dateRange(', + 'FilterPresets::archived()', + ], + ]; + + $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} ({$pattern})"; + } + } + } + + expect($missing)->toBeEmpty('Missing shared filter presets: '.implode(', ', $missing)); +}); + +it('uses centralized option sources for the prioritized status and operation filters', function (): void { + $patternByPath = [ + 'app/Filament/Resources/FindingResource.php' => ['FilterOptionCatalog::findingStatuses()'], + 'app/Filament/Resources/AlertDeliveryResource.php' => ['FilterOptionCatalog::alertDeliveryStatuses()'], + 'app/Filament/Resources/BaselineProfileResource.php' => ['FilterOptionCatalog::baselineProfileStatuses()'], + 'app/Filament/Resources/OperationRunResource.php' => ['FilterOptionCatalog::operationTypes('], + ]; + + $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} ({$pattern})"; + } + } + } + + expect($missing)->toBeEmpty('Missing centralized filter option sourcing: '.implode(', ', $missing)); +}); + it('uses the shared pagination profile helper on standardized surfaces', function (): void { $paths = [ 'app/Filament/Resources/TenantResource.php', diff --git a/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php b/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php index c0ddb10..7f162fa 100644 --- a/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php +++ b/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections; use App\Models\ProviderConnection; use App\Models\Tenant; +use Livewire\Livewire; it('uses tenant_id query override for authorized tenants', function (): void { $tenantA = Tenant::factory()->create(); @@ -63,3 +65,46 @@ ->assertDontSee('A Connection') ->assertDontSee('B Connection'); }); + +it('keeps composed list filters inside the authorized tenant override scope', function (): void { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + [$user] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $tenantAConnection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + 'tenant_id' => (int) $tenantA->getKey(), + 'display_name' => 'A Connected', + 'provider' => 'microsoft', + 'status' => 'connected', + ]); + + $tenantBConnected = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'tenant_id' => (int) $tenantB->getKey(), + 'display_name' => 'B Connected', + 'provider' => 'microsoft', + 'status' => 'connected', + ]); + + $tenantBFailed = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenantB->workspace_id, + 'tenant_id' => (int) $tenantB->getKey(), + 'display_name' => 'B Failed', + 'provider' => 'microsoft', + 'status' => 'error', + ]); + + $this->actingAs($user); + + Livewire::withQueryParams([ + 'tenant_id' => (string) $tenantB->external_id, + ])->test(ListProviderConnections::class) + ->filterTable('status', 'connected') + ->assertCanSeeTableRecords([$tenantBConnected]) + ->assertCanNotSeeTableRecords([$tenantAConnection, $tenantBFailed]); +}); diff --git a/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php index bf0c0f7..e5c12b3 100644 --- a/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php +++ b/tests/Feature/Rbac/InventoryItemResourceAuthorizationTest.php @@ -3,9 +3,12 @@ declare(strict_types=1); use App\Filament\Resources\InventoryItemResource; +use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems; use App\Models\InventoryItem; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; +use Livewire\Livewire; describe('Inventory item resource authorization', function () { it('is not visible for non-members', function () { @@ -53,4 +56,52 @@ expect(InventoryItemResource::canView($record))->toBeTrue(); }); + + it('keeps composed persisted inventory filters scoped to the active tenant', function () { + $tenantA = Tenant::factory()->create(); + [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); + + $tenantB = Tenant::factory()->create([ + 'workspace_id' => (int) $tenantA->workspace_id, + ]); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner'); + + $tenantAFresh = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'display_name' => 'Tenant A Fresh Windows', + 'platform' => 'windows', + 'last_seen_at' => now(), + ]); + + $tenantAStale = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'display_name' => 'Tenant A Stale Windows', + 'platform' => 'windows', + 'last_seen_at' => now()->subDays(3), + ]); + + $tenantBStale = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'display_name' => 'Tenant B Stale Windows', + 'platform' => 'windows', + 'last_seen_at' => now()->subDays(3), + ]); + + $this->actingAs($user); + $tenantA->makeCurrent(); + Filament::setTenant($tenantA, true); + + Livewire::test(ListInventoryItems::class) + ->filterTable('platform', 'windows') + ->filterTable('stale', '1') + ->assertCanSeeTableRecords([$tenantAStale]) + ->assertCanNotSeeTableRecords([$tenantAFresh, $tenantBStale]); + + Livewire::test(ListInventoryItems::class) + ->assertSet('tableFilters.platform.value', 'windows') + ->assertSet('tableFilters.stale.value', '1') + ->assertCanSeeTableRecords([$tenantAStale]) + ->assertCanNotSeeTableRecords([$tenantAFresh, $tenantBStale]); + }); });