From ce567000217d7463dfa151d15e310801398f5197 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 15 Apr 2026 01:27:04 +0200 Subject: [PATCH] Spec 196: restore native Filament table contracts --- .../Pages/Monitoring/EvidenceOverview.php | 482 +++++++++++++++--- .../Pages/TenantRequiredPermissions.php | 470 +++++++++++++---- .../Resources/InventoryItemResource.php | 26 - .../InventoryItemDependencyEdgesTable.php | 264 ++++++++++ ...antRequiredPermissionsViewModelBuilder.php | 8 +- .../components/dependency-edges.blade.php | 87 +--- .../monitoring/evidence-overview.blade.php | 56 +- .../tenant-required-permissions.blade.php | 210 +------- ...tory-item-dependency-edges-table.blade.php | 3 + .../Evidence/EvidenceOverviewPageTest.php | 55 ++ ...nceOverviewDerivedStateMemoizationTest.php | 2 + .../InventoryItemDependencyEdgesTableTest.php | 131 +++++ .../TenantRequiredPermissionsPageTest.php | 126 +++++ .../FilamentTableStandardsGuardTest.php | 90 ++++ .../Feature/InventoryItemDependenciesTest.php | 34 +- ...antRequiredPermissionsTrustedStateTest.php | 53 +- ...ment-nativity-cleanup.logical.openapi.yaml | 7 + .../plan.md | 8 +- .../quickstart.md | 26 +- .../spec.md | 16 +- .../tasks.md | 50 +- 21 files changed, 1596 insertions(+), 608 deletions(-) create mode 100644 apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php create mode 100644 apps/platform/resources/views/livewire/inventory-item-dependency-edges-table.blade.php create mode 100644 apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php create mode 100644 apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php diff --git a/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php b/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php index 2af28f01..9971dbb3 100644 --- a/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php +++ b/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php @@ -20,14 +20,25 @@ use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Workspaces\WorkspaceContext; +use App\Support\Filament\TablePaginationProfiles; use BackedEnum; use Filament\Actions\Action; use Filament\Pages\Page; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Concerns\InteractsWithTable; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Table; use Illuminate\Auth\AuthenticationException; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use UnitEnum; -class EvidenceOverview extends Page +class EvidenceOverview extends Page implements HasTable { + use InteractsWithTable; + protected static bool $isDiscovered = false; protected static bool $shouldRegisterNavigation = false; @@ -45,7 +56,12 @@ class EvidenceOverview extends Page */ public array $rows = []; - public ?int $tenantFilter = null; + /** + * @var array|null + */ + private ?array $accessibleTenants = null; + + private ?Collection $cachedSnapshots = null; public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration { @@ -59,85 +75,92 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public function mount(): void { - $user = auth()->user(); + $this->authorizeWorkspaceAccess(); + $this->seedTableStateFromQuery(); + $this->rows = $this->rowsForState($this->tableFilters ?? [], $this->tableSearch)->values()->all(); - if (! $user instanceof User) { - throw new AuthenticationException; - } + $this->mountInteractsWithTable(); + } - $workspaceContext = app(WorkspaceContext::class); - $workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request()); - $workspaceId = (int) $workspace->getKey(); + public function table(Table $table): Table + { + return $table + ->defaultSort('tenant_name') + ->defaultPaginationPageOption(25) + ->paginated(TablePaginationProfiles::customPage()) + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() + ->searchable() + ->searchPlaceholder('Search tenant or next step') + ->records(function ( + ?string $sortColumn, + ?string $sortDirection, + ?string $search, + array $filters, + int $page, + int $recordsPerPage + ): LengthAwarePaginator { + $rows = $this->rowsForState($filters, $search); + $rows = $this->sortRows($rows, $sortColumn, $sortDirection); - $accessibleTenants = $user->tenants() - ->where('tenants.workspace_id', $workspaceId) - ->orderBy('tenants.name') - ->get() - ->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant)) - ->values(); - - $this->tenantFilter = is_numeric(request()->query('tenant_id')) ? (int) request()->query('tenant_id') : null; - - $tenantIds = $accessibleTenants->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all(); - - $query = EvidenceSnapshot::query() - ->with('tenant') - ->where('workspace_id', $workspaceId) - ->whereIn('tenant_id', $tenantIds) - ->where('status', 'active') - ->latest('generated_at'); - - if ($this->tenantFilter !== null) { - $query->where('tenant_id', $this->tenantFilter); - } - - $snapshots = $query->get()->unique('tenant_id')->values(); - $currentReviewTenantIds = TenantReview::query() - ->where('workspace_id', $workspaceId) - ->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all()) - ->whereIn('status', [ - TenantReviewStatus::Draft->value, - TenantReviewStatus::Ready->value, - TenantReviewStatus::Published->value, + return $this->paginateRows($rows, $page, $recordsPerPage); + }) + ->filters([ + SelectFilter::make('tenant_id') + ->label('Tenant') + ->options(fn (): array => $this->tenantFilterOptions()) + ->searchable(), ]) - ->pluck('tenant_id') - ->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true]) - ->all(); - - $this->rows = $snapshots->map(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array { - $truth = $this->snapshotTruth($snapshot); - $freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState); - $tenantId = (int) $snapshot->tenant_id; - $hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false; - $nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current' - ? 'Create a current review from this evidence snapshot' - : $truth->nextStepText(); - - return [ - 'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant', - 'tenant_id' => $tenantId, - 'snapshot_id' => (int) $snapshot->getKey(), - 'completeness_state' => (string) $snapshot->completeness_state, - 'generated_at' => $snapshot->generated_at?->toDateTimeString(), - 'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)), - 'stale_dimensions' => (int) (($snapshot->summary['stale_dimensions'] ?? 0)), - 'artifact_truth' => [ - 'label' => $truth->primaryLabel, - 'color' => $truth->primaryBadgeSpec()->color, - 'icon' => $truth->primaryBadgeSpec()->icon, - 'explanation' => $truth->primaryExplanation, - ], - 'freshness' => [ - 'label' => $freshnessSpec->label, - 'color' => $freshnessSpec->color, - 'icon' => $freshnessSpec->icon, - ], - 'next_step' => $nextStep, - 'view_url' => $snapshot->tenant - ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) - : null, - ]; - })->all(); + ->columns([ + TextColumn::make('tenant_name') + ->label('Tenant') + ->sortable(), + TextColumn::make('artifact_truth_label') + ->label('Artifact truth') + ->badge() + ->color(fn (array $record): string => (string) ($record['artifact_truth_color'] ?? 'gray')) + ->icon(fn (array $record): ?string => is_string($record['artifact_truth_icon'] ?? null) ? $record['artifact_truth_icon'] : null) + ->description(fn (array $record): ?string => is_string($record['artifact_truth_explanation'] ?? null) ? $record['artifact_truth_explanation'] : null) + ->sortable() + ->wrap(), + TextColumn::make('freshness_label') + ->label('Freshness') + ->badge() + ->color(fn (array $record): string => (string) ($record['freshness_color'] ?? 'gray')) + ->icon(fn (array $record): ?string => is_string($record['freshness_icon'] ?? null) ? $record['freshness_icon'] : null) + ->sortable(), + TextColumn::make('generated_at') + ->label('Generated') + ->placeholder('—') + ->sortable(), + TextColumn::make('missing_dimensions') + ->label('Not collected yet') + ->numeric() + ->sortable(), + TextColumn::make('stale_dimensions') + ->label('Refresh recommended') + ->numeric() + ->sortable(), + TextColumn::make('next_step') + ->label('Next step') + ->wrap(), + ]) + ->recordUrl(fn ($record): ?string => is_array($record) ? (is_string($record['view_url'] ?? null) ? $record['view_url'] : null) : null) + ->actions([]) + ->bulkActions([]) + ->emptyStateHeading('No evidence snapshots in this scope') + ->emptyStateDescription(fn (): string => $this->hasActiveOverviewFilters() + ? 'Clear the current filters to return to the full workspace evidence overview.' + : 'Adjust filters or create a tenant snapshot to populate the workspace overview.') + ->emptyStateActions([ + Action::make('clear_filters') + ->label('Clear filters') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->hasActiveOverviewFilters()) + ->action(fn (): mixed => $this->clearOverviewFilters()), + ]); } /** @@ -149,11 +172,26 @@ protected function getHeaderActions(): array Action::make('clear_filters') ->label('Clear filters') ->color('gray') - ->visible(fn (): bool => $this->tenantFilter !== null) - ->url(route('admin.evidence.overview')), + ->visible(fn (): bool => $this->hasActiveOverviewFilters()) + ->action(fn (): mixed => $this->clearOverviewFilters()), ]; } + public function clearOverviewFilters(): void + { + $this->tableFilters = [ + 'tenant_id' => ['value' => null], + ]; + $this->tableDeferredFilters = $this->tableFilters; + $this->tableSearch = ''; + $this->rows = $this->rowsForState($this->tableFilters, $this->tableSearch)->values()->all(); + + session()->put($this->getTableFiltersSessionKey(), $this->tableFilters); + session()->put($this->getTableSearchSessionKey(), $this->tableSearch); + + $this->resetPage(); + } + private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope { $presenter = app(ArtifactTruthPresenter::class); @@ -162,4 +200,290 @@ private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ? $presenter->forEvidenceSnapshotFresh($snapshot) : $presenter->forEvidenceSnapshot($snapshot); } + + private function authorizeWorkspaceAccess(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + throw new AuthenticationException; + } + + app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request()); + } + + /** + * @return array + */ + private function accessibleTenants(): array + { + if (is_array($this->accessibleTenants)) { + return $this->accessibleTenants; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return $this->accessibleTenants = []; + } + + $workspaceId = $this->workspaceId(); + + return $this->accessibleTenants = $user->tenants() + ->where('tenants.workspace_id', $workspaceId) + ->orderBy('tenants.name') + ->get() + ->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant)) + ->values() + ->all(); + } + + /** + * @return array + */ + private function tenantFilterOptions(): array + { + return collect($this->accessibleTenants()) + ->mapWithKeys(static fn (Tenant $tenant): array => [ + (string) $tenant->getKey() => $tenant->name, + ]) + ->all(); + } + + /** + * @param array $filters + * @return Collection> + */ + private function rowsForState(array $filters = [], ?string $search = null): Collection + { + $rows = $this->baseRows(); + $tenantFilter = $this->normalizeTenantFilter($filters['tenant_id']['value'] ?? data_get($this->tableFilters, 'tenant_id.value')); + $normalizedSearch = Str::lower(trim((string) ($search ?? $this->tableSearch))); + + if ($tenantFilter !== null) { + $rows = $rows->where('tenant_id', $tenantFilter); + } + + if ($normalizedSearch === '') { + return $rows; + } + + return $rows->filter(function (array $row) use ($normalizedSearch): bool { + $haystack = implode(' ', [ + (string) ($row['tenant_name'] ?? ''), + (string) ($row['artifact_truth_label'] ?? ''), + (string) ($row['artifact_truth_explanation'] ?? ''), + (string) ($row['freshness_label'] ?? ''), + (string) ($row['next_step'] ?? ''), + ]); + + return str_contains(Str::lower($haystack), $normalizedSearch); + }); + } + + /** + * @return Collection> + */ + private function baseRows(): Collection + { + $snapshots = $this->latestAccessibleSnapshots(); + $currentReviewTenantIds = $this->currentReviewTenantIds($snapshots); + + return $snapshots->mapWithKeys(function (EvidenceSnapshot $snapshot) use ($currentReviewTenantIds): array { + return [(string) $snapshot->getKey() => $this->rowForSnapshot($snapshot, $currentReviewTenantIds)]; + }); + } + + /** + * @return Collection + */ + private function latestAccessibleSnapshots(): Collection + { + if ($this->cachedSnapshots instanceof Collection) { + return $this->cachedSnapshots; + } + + $tenantIds = collect($this->accessibleTenants()) + ->map(static fn (Tenant $tenant): int => (int) $tenant->getKey()) + ->all(); + + $query = EvidenceSnapshot::query() + ->with('tenant') + ->where('workspace_id', $this->workspaceId()) + ->where('status', 'active') + ->latest('generated_at'); + + if ($tenantIds === []) { + $query->whereRaw('1 = 0'); + } else { + $query->whereIn('tenant_id', $tenantIds); + } + + return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values(); + } + + /** + * @param Collection $snapshots + * @return array + */ + private function currentReviewTenantIds(Collection $snapshots): array + { + return TenantReview::query() + ->where('workspace_id', $this->workspaceId()) + ->whereIn('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all()) + ->whereIn('status', [ + TenantReviewStatus::Draft->value, + TenantReviewStatus::Ready->value, + TenantReviewStatus::Published->value, + ]) + ->pluck('tenant_id') + ->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true]) + ->all(); + } + + /** + * @param array $currentReviewTenantIds + * @return array + */ + private function rowForSnapshot(EvidenceSnapshot $snapshot, array $currentReviewTenantIds): array + { + $truth = $this->snapshotTruth($snapshot); + $freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState); + $tenantId = (int) $snapshot->tenant_id; + $hasCurrentReview = $currentReviewTenantIds[$tenantId] ?? false; + $nextStep = ! $hasCurrentReview && $truth->contentState === 'trusted' && $truth->freshnessState === 'current' + ? 'Create a current review from this evidence snapshot' + : $truth->nextStepText(); + + return [ + 'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant', + 'tenant_id' => $tenantId, + 'snapshot_id' => (int) $snapshot->getKey(), + 'generated_at' => $snapshot->generated_at?->toDateTimeString(), + 'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0), + 'stale_dimensions' => (int) ($snapshot->summary['stale_dimensions'] ?? 0), + 'artifact_truth_label' => $truth->primaryLabel, + 'artifact_truth_color' => $truth->primaryBadgeSpec()->color, + 'artifact_truth_icon' => $truth->primaryBadgeSpec()->icon, + 'artifact_truth_explanation' => $truth->primaryExplanation, + 'artifact_truth' => [ + 'label' => $truth->primaryLabel, + 'color' => $truth->primaryBadgeSpec()->color, + 'icon' => $truth->primaryBadgeSpec()->icon, + 'explanation' => $truth->primaryExplanation, + ], + 'freshness_label' => $freshnessSpec->label, + 'freshness_color' => $freshnessSpec->color, + 'freshness_icon' => $freshnessSpec->icon, + 'freshness' => [ + 'label' => $freshnessSpec->label, + 'color' => $freshnessSpec->color, + 'icon' => $freshnessSpec->icon, + ], + 'next_step' => $nextStep, + 'view_url' => $snapshot->tenant + ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) + : null, + ]; + } + + /** + * @param Collection> $rows + * @return Collection> + */ + private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection + { + $sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true) + ? $sortColumn + : 'tenant_name'; + $descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc'; + + $records = $rows->all(); + + uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int { + $comparison = in_array($sortColumn, ['missing_dimensions', 'stale_dimensions'], true) + ? ((int) ($left[$sortColumn] ?? 0)) <=> ((int) ($right[$sortColumn] ?? 0)) + : strnatcasecmp((string) ($left[$sortColumn] ?? ''), (string) ($right[$sortColumn] ?? '')); + + if ($comparison === 0) { + $comparison = strnatcasecmp((string) ($left['tenant_name'] ?? ''), (string) ($right['tenant_name'] ?? '')); + } + + return $descending ? ($comparison * -1) : $comparison; + }); + + return collect($records); + } + + /** + * @param Collection> $rows + */ + private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator + { + return new LengthAwarePaginator( + items: $rows->forPage($page, $recordsPerPage), + total: $rows->count(), + perPage: $recordsPerPage, + currentPage: $page, + ); + } + + private function seedTableStateFromQuery(): void + { + $query = request()->query(); + + if (array_key_exists('search', $query)) { + $this->tableSearch = trim((string) request()->query('search', '')); + } + + if (! array_key_exists('tenant_id', $query)) { + return; + } + + $tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id')); + + if ($tenantFilter === null) { + return; + } + + $this->tableFilters = [ + 'tenant_id' => ['value' => (string) $tenantFilter], + ]; + $this->tableDeferredFilters = $this->tableFilters; + } + + private function normalizeTenantFilter(mixed $value): ?int + { + if (! is_numeric($value)) { + return null; + } + + $requestedTenantId = (int) $value; + $allowedTenantIds = collect($this->accessibleTenants()) + ->map(static fn (Tenant $tenant): int => (int) $tenant->getKey()) + ->all(); + + return in_array($requestedTenantId, $allowedTenantIds, true) + ? $requestedTenantId + : null; + } + + private function hasActiveOverviewFilters(): bool + { + return filled(data_get($this->tableFilters, 'tenant_id.value')) + || trim((string) $this->tableSearch) !== ''; + } + + private function workspaceId(): int + { + $user = auth()->user(); + + if (! $user instanceof User) { + throw new AuthenticationException; + } + + return (int) app(WorkspaceContext::class) + ->currentWorkspaceForMemberOrFail($user, request()) + ->getKey(); + } } diff --git a/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php b/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php index 91ce2f4e..5b522a51 100644 --- a/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php +++ b/apps/platform/app/Filament/Pages/TenantRequiredPermissions.php @@ -10,16 +10,30 @@ use App\Models\User; use App\Models\WorkspaceMembership; use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; +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\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Workspaces\WorkspaceContext; +use Filament\Actions\Action; use Filament\Pages\Page; +use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Concerns\InteractsWithTable; +use Filament\Tables\Contracts\HasTable; +use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Table; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Livewire\Attributes\Locked; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -class TenantRequiredPermissions extends Page +class TenantRequiredPermissions extends Page implements HasTable { + use InteractsWithTable; + protected static bool $isDiscovered = false; protected static bool $shouldRegisterNavigation = false; @@ -40,25 +54,16 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration ->satisfy(ActionSurfaceSlot::ListEmptyState, 'The inline permissions matrix provides purposeful no-data, all-clear, and no-matches states with verification or reset guidance.'); } - public string $status = 'missing'; - - public string $type = 'all'; - - /** - * @var array - */ - public array $features = []; - - public string $search = ''; - - /** - * @var array - */ - public array $viewModel = []; - #[Locked] public ?int $scopedTenantId = null; + /** + * @var array|null + */ + private ?array $cachedViewModel = null; + + private ?string $cachedViewModelStateKey = null; + public static function canAccess(): bool { return static::hasScopedTenantAccess(static::resolveScopedTenant()); @@ -69,9 +74,9 @@ public function currentTenant(): ?Tenant return $this->trustedScopedTenant(); } - public function mount(): void + public function mount(Tenant|string|null $tenant = null): void { - $tenant = static::resolveScopedTenant(); + $tenant = static::resolveScopedTenant($tenant); if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) { abort(404); @@ -81,109 +86,120 @@ public function mount(): void $this->heading = $tenant->getFilamentName(); $this->subheading = 'Required permissions'; - $queryFeatures = request()->query('features', $this->features); - - $state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ - 'status' => request()->query('status', $this->status), - 'type' => request()->query('type', $this->type), - 'features' => is_array($queryFeatures) ? $queryFeatures : [], - 'search' => request()->query('search', $this->search), - ]); - - $this->status = $state['status']; - $this->type = $state['type']; - $this->features = $state['features']; - $this->search = $state['search']; - - $this->refreshViewModel(); + $this->seedTableStateFromQuery(); + $this->mountInteractsWithTable(); } - public function updatedStatus(): void + public function table(Table $table): Table { - $this->refreshViewModel(); + return $table + ->defaultSort('sort_priority') + ->defaultPaginationPageOption(25) + ->paginated(TablePaginationProfiles::customPage()) + ->persistFiltersInSession() + ->persistSearchInSession() + ->persistSortInSession() + ->searchable() + ->searchPlaceholder('Search permission key or description…') + ->records(function ( + ?string $sortColumn, + ?string $sortDirection, + ?string $search, + array $filters, + int $page, + int $recordsPerPage + ): LengthAwarePaginator { + $state = $this->filterState(filters: $filters, search: $search); + $rows = $this->permissionRowsForState($state); + $rows = $this->sortPermissionRows($rows, $sortColumn, $sortDirection); + + return $this->paginatePermissionRows($rows, $page, $recordsPerPage); + }) + ->filters([ + SelectFilter::make('status') + ->label('Status') + ->default('missing') + ->options([ + 'missing' => 'Missing', + 'present' => 'Present', + 'all' => 'All', + ]), + SelectFilter::make('type') + ->label('Type') + ->default('all') + ->options([ + 'all' => 'All', + 'application' => 'Application', + 'delegated' => 'Delegated', + ]), + SelectFilter::make('features') + ->label('Features') + ->multiple() + ->options(fn (): array => $this->featureFilterOptions()) + ->searchable(), + ]) + ->columns([ + TextColumn::make('key') + ->label('Permission') + ->description(fn (array $record): ?string => is_string($record['description'] ?? null) ? $record['description'] : null) + ->wrap() + ->sortable(), + TextColumn::make('type_label') + ->label('Type') + ->badge() + ->color('gray') + ->sortable(), + TextColumn::make('status') + ->label('Status') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus)) + ->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)) + ->sortable(), + TextColumn::make('features_label') + ->label('Features') + ->wrap() + ->toggleable(), + ]) + ->actions([]) + ->bulkActions([]) + ->emptyStateHeading(fn (): string => $this->permissionsEmptyStateHeading()) + ->emptyStateDescription(fn (): string => $this->permissionsEmptyStateDescription()) + ->emptyStateActions([ + Action::make('clear_filters') + ->label('Clear filters') + ->icon('heroicon-o-x-mark') + ->color('gray') + ->visible(fn (): bool => $this->hasActivePermissionFilters()) + ->action(fn (): mixed => $this->clearPermissionFilters()), + ]); } - public function updatedType(): void + /** + * @return array + */ + public function viewModel(): array { - $this->refreshViewModel(); + return $this->viewModelForState($this->filterState()); } - public function updatedFeatures(): void + public function clearPermissionFilters(): void { - $this->refreshViewModel(); - } + $this->tableFilters = [ + 'status' => ['value' => 'missing'], + 'type' => ['value' => 'all'], + 'features' => ['values' => []], + ]; + $this->tableDeferredFilters = $this->tableFilters; + $this->tableSearch = ''; + $this->cachedViewModel = null; + $this->cachedViewModelStateKey = null; - public function updatedSearch(): void - { - $this->refreshViewModel(); - } + session()->put($this->getTableFiltersSessionKey(), $this->tableFilters); + session()->put($this->getTableSearchSessionKey(), $this->tableSearch); - public function applyFeatureFilter(string $feature): void - { - $feature = trim($feature); - - if ($feature === '') { - return; - } - - if (in_array($feature, $this->features, true)) { - $this->features = array_values(array_filter( - $this->features, - static fn (string $value): bool => $value !== $feature, - )); - } else { - $this->features[] = $feature; - } - - $this->features = array_values(array_unique($this->features)); - - $this->refreshViewModel(); - } - - public function clearFeatureFilter(): void - { - $this->features = []; - - $this->refreshViewModel(); - } - - public function resetFilters(): void - { - $this->status = 'missing'; - $this->type = 'all'; - $this->features = []; - $this->search = ''; - - $this->refreshViewModel(); - } - - private function refreshViewModel(): void - { - $tenant = $this->trustedScopedTenant(); - - if (! $tenant instanceof Tenant) { - $this->viewModel = []; - - return; - } - - $builder = app(TenantRequiredPermissionsViewModelBuilder::class); - - $this->viewModel = $builder->build($tenant, [ - 'status' => $this->status, - 'type' => $this->type, - 'features' => $this->features, - 'search' => $this->search, - ]); - - $filters = $this->viewModel['filters'] ?? null; - - if (is_array($filters)) { - $this->status = (string) ($filters['status'] ?? $this->status); - $this->type = (string) ($filters['type'] ?? $this->type); - $this->features = is_array($filters['features'] ?? null) ? $filters['features'] : $this->features; - $this->search = (string) ($filters['search'] ?? $this->search); - } + $this->resetPage(); } public function reRunVerificationUrl(): string @@ -208,8 +224,18 @@ public function manageProviderConnectionUrl(): ?string return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); } - protected static function resolveScopedTenant(): ?Tenant + protected static function resolveScopedTenant(Tenant|string|null $tenant = null): ?Tenant { + if ($tenant instanceof Tenant) { + return $tenant; + } + + if (is_string($tenant) && $tenant !== '') { + return Tenant::query() + ->where('external_id', $tenant) + ->first(); + } + $routeTenant = request()->route('tenant'); if ($routeTenant instanceof Tenant) { @@ -222,6 +248,14 @@ protected static function resolveScopedTenant(): ?Tenant ->first(); } + $queryTenant = request()->query('tenant'); + + if (is_string($queryTenant) && $queryTenant !== '') { + return Tenant::query() + ->where('external_id', $queryTenant) + ->first(); + } + return null; } @@ -293,4 +327,216 @@ private function trustedScopedTenant(): ?Tenant return null; } } + + /** + * @param array $filters + * @return array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array,search:string} + */ + private function filterState(array $filters = [], ?string $search = null): array + { + return TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ + 'status' => $filters['status']['value'] ?? data_get($this->tableFilters, 'status.value'), + 'type' => $filters['type']['value'] ?? data_get($this->tableFilters, 'type.value'), + 'features' => $filters['features']['values'] ?? data_get($this->tableFilters, 'features.values', []), + 'search' => $search ?? $this->tableSearch, + ]); + } + + /** + * @param array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array,search:string} $state + * @return array + */ + private function viewModelForState(array $state): array + { + $tenant = $this->trustedScopedTenant(); + + if (! $tenant instanceof Tenant) { + return []; + } + + $stateKey = json_encode([$tenant->getKey(), $state]); + + if ($this->cachedViewModelStateKey === $stateKey && is_array($this->cachedViewModel)) { + return $this->cachedViewModel; + } + + $builder = app(TenantRequiredPermissionsViewModelBuilder::class); + + $this->cachedViewModelStateKey = $stateKey ?: null; + $this->cachedViewModel = $builder->build($tenant, $state); + + return $this->cachedViewModel; + } + + /** + * @return Collection> + */ + private function permissionRowsForState(array $state): Collection + { + return collect($this->viewModelForState($state)['permissions'] ?? []) + ->filter(fn (mixed $row): bool => is_array($row) && is_string($row['key'] ?? null)) + ->mapWithKeys(function (array $row): array { + $key = (string) $row['key']; + + return [ + $key => [ + 'key' => $key, + 'description' => is_string($row['description'] ?? null) ? $row['description'] : null, + 'type' => (string) ($row['type'] ?? 'application'), + 'type_label' => ($row['type'] ?? 'application') === 'delegated' ? 'Delegated' : 'Application', + 'status' => (string) ($row['status'] ?? 'missing'), + 'features_label' => implode(', ', array_filter((array) ($row['features'] ?? []), 'is_string')), + 'sort_priority' => $this->statusSortWeight((string) ($row['status'] ?? 'missing')), + ], + ]; + }); + } + + /** + * @param Collection> $rows + * @return Collection> + */ + private function sortPermissionRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection + { + $sortColumn = in_array($sortColumn, ['sort_priority', 'key', 'type_label', 'status', 'features_label'], true) + ? $sortColumn + : 'sort_priority'; + $descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc'; + + $records = $rows->all(); + + uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int { + $comparison = match ($sortColumn) { + 'sort_priority' => ((int) ($left['sort_priority'] ?? 0)) <=> ((int) ($right['sort_priority'] ?? 0)), + default => strnatcasecmp( + (string) ($left[$sortColumn] ?? ''), + (string) ($right[$sortColumn] ?? ''), + ), + }; + + if ($comparison === 0) { + $comparison = strnatcasecmp( + (string) ($left['key'] ?? ''), + (string) ($right['key'] ?? ''), + ); + } + + return $descending ? ($comparison * -1) : $comparison; + }); + + return collect($records); + } + + /** + * @param Collection> $rows + */ + private function paginatePermissionRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator + { + return new LengthAwarePaginator( + items: $rows->forPage($page, $recordsPerPage), + total: $rows->count(), + perPage: $recordsPerPage, + currentPage: $page, + ); + } + + /** + * @return array + */ + private function featureFilterOptions(): array + { + return collect(data_get($this->viewModelForState([ + 'status' => 'all', + 'type' => 'all', + 'features' => [], + 'search' => '', + ]), 'overview.feature_impacts', [])) + ->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null)) + ->mapWithKeys(fn (array $impact): array => [ + (string) $impact['feature'] => (string) $impact['feature'], + ]) + ->all(); + } + + private function permissionsEmptyStateHeading(): string + { + $viewModel = $this->viewModel(); + $counts = is_array(data_get($viewModel, 'overview.counts')) ? data_get($viewModel, 'overview.counts') : []; + $state = $this->filterState(); + $allPermissions = data_get($this->viewModelForState([ + 'status' => 'all', + 'type' => 'all', + 'features' => [], + 'search' => '', + ]), 'permissions', []); + + $missingTotal = (int) ($counts['missing_application'] ?? 0) + + (int) ($counts['missing_delegated'] ?? 0) + + (int) ($counts['error'] ?? 0); + $requiredTotal = $missingTotal + (int) ($counts['present'] ?? 0); + + if (! is_array($allPermissions) || $allPermissions === []) { + return 'No permissions configured'; + } + + if ($state['status'] === 'missing' && $missingTotal === 0 && $state['type'] === 'all' && $state['features'] === [] && trim($state['search']) === '') { + return 'All required permissions are present'; + } + + return 'No matches'; + } + + private function permissionsEmptyStateDescription(): string + { + return match ($this->permissionsEmptyStateHeading()) { + 'No permissions configured' => 'No required permissions are currently configured in config/intune_permissions.php.', + 'All required permissions are present' => 'Switch Status to All if you want to review the full matrix.', + default => 'No permissions match the current filters.', + }; + } + + private function hasActivePermissionFilters(): bool + { + $state = $this->filterState(); + + return $state['status'] !== 'missing' + || $state['type'] !== 'all' + || $state['features'] !== [] + || trim($state['search']) !== ''; + } + + private function seedTableStateFromQuery(): void + { + $query = request()->query(); + + if (! array_key_exists('status', $query) && ! array_key_exists('type', $query) && ! array_key_exists('features', $query) && ! array_key_exists('search', $query)) { + return; + } + + $queryFeatures = request()->query('features', []); + + $state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ + 'status' => request()->query('status', 'missing'), + 'type' => request()->query('type', 'all'), + 'features' => is_array($queryFeatures) ? $queryFeatures : [], + 'search' => request()->query('search', ''), + ]); + + $this->tableFilters = [ + 'status' => ['value' => $state['status']], + 'type' => ['value' => $state['type']], + 'features' => ['values' => $state['features']], + ]; + $this->tableDeferredFilters = $this->tableFilters; + $this->tableSearch = $state['search']; + } + + private function statusSortWeight(string $status): int + { + return match ($status) { + 'missing' => 0, + 'error' => 1, + default => 2, + }; + } } diff --git a/apps/platform/app/Filament/Resources/InventoryItemResource.php b/apps/platform/app/Filament/Resources/InventoryItemResource.php index 2145081b..4ab1b8a9 100644 --- a/apps/platform/app/Filament/Resources/InventoryItemResource.php +++ b/apps/platform/app/Filament/Resources/InventoryItemResource.php @@ -10,14 +10,11 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Auth\CapabilityResolver; -use App\Services\Inventory\DependencyQueryService; -use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; 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; @@ -179,29 +176,6 @@ public static function infolist(Schema $schema): Schema ViewEntry::make('dependencies') ->label('') ->view('filament.components.dependency-edges') - ->state(function (InventoryItem $record) { - $direction = request()->query('direction', 'all'); - $relationshipType = request()->query('relationship_type', 'all'); - $relationshipType = is_string($relationshipType) ? $relationshipType : 'all'; - - $relationshipType = $relationshipType === 'all' - ? null - : RelationshipType::tryFrom($relationshipType)?->value; - - $service = app(DependencyQueryService::class); - $resolver = app(DependencyTargetResolver::class); - $tenant = static::resolveTenantContextForCurrentPanel(); - - $edges = collect(); - if ($direction === 'inbound' || $direction === 'all') { - $edges = $edges->merge($service->getInboundEdges($record, $relationshipType)); - } - if ($direction === 'outbound' || $direction === 'all') { - $edges = $edges->merge($service->getOutboundEdges($record, $relationshipType)); - } - - return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined - }) ->columnSpanFull(), ]) ->columnSpanFull(), diff --git a/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php b/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php new file mode 100644 index 00000000..27d718d7 --- /dev/null +++ b/apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php @@ -0,0 +1,264 @@ +inventoryItemId = $inventoryItemId; + + $this->resolveInventoryItem(); + } + + public function table(Table $table): Table + { + return $table + ->queryStringIdentifier('inventoryItemDependencyEdges'.Str::studly((string) $this->inventoryItemId)) + ->defaultSort('relationship_label') + ->defaultPaginationPageOption(10) + ->paginated(TablePaginationProfiles::picker()) + ->striped() + ->deferLoading(! app()->runningUnitTests()) + ->records(function ( + ?string $sortColumn, + ?string $sortDirection, + ?string $search, + array $filters, + int $page, + int $recordsPerPage + ): LengthAwarePaginator { + $rows = $this->dependencyRows( + direction: (string) ($filters['direction']['value'] ?? 'all'), + relationshipType: $this->normalizeRelationshipType($filters['relationship_type']['value'] ?? null), + ); + + $rows = $this->sortRows($rows, $sortColumn, $sortDirection); + + return $this->paginateRows($rows, $page, $recordsPerPage); + }) + ->filters([ + SelectFilter::make('direction') + ->label('Direction') + ->default('all') + ->options([ + 'all' => 'All', + 'inbound' => 'Inbound', + 'outbound' => 'Outbound', + ]), + SelectFilter::make('relationship_type') + ->label('Relationship') + ->options([ + 'all' => 'All', + ...RelationshipType::options(), + ]) + ->default('all') + ->searchable(), + ]) + ->columns([ + TextColumn::make('relationship_label') + ->label('Relationship') + ->badge() + ->sortable(), + TextColumn::make('target_label') + ->label('Target') + ->badge() + ->url(fn (array $record): ?string => is_string($record['target_url'] ?? null) ? $record['target_url'] : null) + ->tooltip(fn (array $record): ?string => is_string($record['target_tooltip'] ?? null) ? $record['target_tooltip'] : null) + ->wrap(), + TextColumn::make('missing_state') + ->label('Status') + ->badge() + ->placeholder('—') + ->color(fn (?string $state): string => $state === 'Missing' ? 'danger' : 'gray') + ->icon(fn (?string $state): ?string => $state === 'Missing' ? 'heroicon-m-exclamation-triangle' : null) + ->description(fn (array $record): ?string => is_string($record['missing_hint'] ?? null) ? $record['missing_hint'] : null) + ->wrap(), + ]) + ->actions([]) + ->bulkActions([]) + ->emptyStateHeading('No dependencies found') + ->emptyStateDescription('Change direction or relationship filters to review a different dependency scope.'); + } + + public function render(): View + { + return view('livewire.inventory-item-dependency-edges-table'); + } + + /** + * @return Collection> + */ + private function dependencyRows(string $direction, ?string $relationshipType): Collection + { + $inventoryItem = $this->resolveInventoryItem(); + $tenant = $this->resolveCurrentTenant(); + $service = app(DependencyQueryService::class); + $resolver = app(DependencyTargetResolver::class); + + $edges = collect(); + + if ($direction === 'inbound' || $direction === 'all') { + $edges = $edges->merge($service->getInboundEdges($inventoryItem, $relationshipType)); + } + + if ($direction === 'outbound' || $direction === 'all') { + $edges = $edges->merge($service->getOutboundEdges($inventoryItem, $relationshipType)); + } + + return $resolver->attachRenderedTargets($edges->take(100), $tenant) + ->map(function (array $edge): array { + $targetId = $edge['target_id'] ?? null; + $renderedTarget = is_array($edge['rendered_target'] ?? null) ? $edge['rendered_target'] : []; + $badgeText = is_string($renderedTarget['badge_text'] ?? null) ? $renderedTarget['badge_text'] : null; + $linkUrl = is_string($renderedTarget['link_url'] ?? null) ? $renderedTarget['link_url'] : null; + $lastKnownName = is_string(data_get($edge, 'metadata.last_known_name')) ? data_get($edge, 'metadata.last_known_name') : null; + $isMissing = ($edge['target_type'] ?? null) === 'missing'; + + $missingHint = null; + + if ($isMissing) { + $missingHint = 'Missing target'; + + if (filled($lastKnownName)) { + $missingHint .= ". Last known: {$lastKnownName}"; + } + + $rawRef = data_get($edge, 'metadata.raw_ref'); + $encodedRef = $rawRef !== null ? json_encode($rawRef) : null; + + if (is_string($encodedRef) && $encodedRef !== '') { + $missingHint .= '. Ref: '.Str::limit($encodedRef, 200); + } + } + + $fallbackLabel = null; + + if (filled($lastKnownName)) { + $fallbackLabel = $lastKnownName; + } elseif (is_string($targetId) && $targetId !== '') { + $fallbackLabel = 'ID: '.substr($targetId, 0, 6).'…'; + } else { + $fallbackLabel = 'External reference'; + } + + $relationshipType = (string) ($edge['relationship_type'] ?? 'unknown'); + + return [ + 'id' => (string) ($edge['id'] ?? Str::uuid()->toString()), + 'relationship_label' => RelationshipType::options()[$relationshipType] ?? Str::headline($relationshipType), + 'target_label' => $badgeText ?? $fallbackLabel, + 'target_url' => $linkUrl, + 'target_tooltip' => is_string($targetId) ? $targetId : null, + 'missing_state' => $isMissing ? 'Missing' : null, + 'missing_hint' => $missingHint, + ]; + }) + ->mapWithKeys(fn (array $row): array => [$row['id'] => $row]); + } + + /** + * @param Collection> $rows + * @return Collection> + */ + private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection + { + $sortColumn = in_array($sortColumn, ['relationship_label', 'target_label', 'missing_state'], true) + ? $sortColumn + : 'relationship_label'; + $descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc'; + + $records = $rows->all(); + + uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int { + $comparison = strnatcasecmp( + (string) ($left[$sortColumn] ?? ''), + (string) ($right[$sortColumn] ?? ''), + ); + + if ($comparison === 0) { + $comparison = strnatcasecmp( + (string) ($left['target_label'] ?? ''), + (string) ($right['target_label'] ?? ''), + ); + } + + return $descending ? ($comparison * -1) : $comparison; + }); + + return collect($records); + } + + /** + * @param Collection> $rows + */ + private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator + { + return new LengthAwarePaginator( + items: $rows->forPage($page, $recordsPerPage), + total: $rows->count(), + perPage: $recordsPerPage, + currentPage: $page, + ); + } + + private function resolveInventoryItem(): InventoryItem + { + if ($this->cachedInventoryItem instanceof InventoryItem) { + return $this->cachedInventoryItem; + } + + $inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId); + $tenant = $this->resolveCurrentTenant(); + + if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) { + throw new NotFoundHttpException; + } + + return $this->cachedInventoryItem = $inventoryItem; + } + + private function resolveCurrentTenant(): Tenant + { + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Tenant) { + throw new NotFoundHttpException; + } + + return $tenant; + } + + private function normalizeRelationshipType(mixed $value): ?string + { + if (! is_string($value) || $value === '' || $value === 'all') { + return null; + } + + return RelationshipType::tryFrom($value)?->value; + } +} \ No newline at end of file diff --git a/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php b/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php index a6d9a5a0..3656aedc 100644 --- a/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php +++ b/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php @@ -53,6 +53,8 @@ public function build(Tenant $tenant, array $filters = []): array $filteredPermissions = self::applyFilterState($allPermissions, $state); $freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null)); + $summaryPermissions = $filteredPermissions; + return [ 'tenant' => [ 'id' => (int) $tenant->getKey(), @@ -60,9 +62,9 @@ public function build(Tenant $tenant, array $filters = []): array 'name' => (string) $tenant->name, ], 'overview' => [ - 'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)), - 'counts' => self::deriveCounts($allPermissions), - 'feature_impacts' => self::deriveFeatureImpacts($allPermissions), + 'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)), + 'counts' => self::deriveCounts($summaryPermissions), + 'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions), 'freshness' => $freshness, ], 'permissions' => $filteredPermissions, diff --git a/apps/platform/resources/views/filament/components/dependency-edges.blade.php b/apps/platform/resources/views/filament/components/dependency-edges.blade.php index 146913ce..0becc6bf 100644 --- a/apps/platform/resources/views/filament/components/dependency-edges.blade.php +++ b/apps/platform/resources/views/filament/components/dependency-edges.blade.php @@ -1,85 +1,6 @@ -@php /** @var callable $getState */ @endphp -
-
- - - - - - -
- - @php - $raw = $getState(); - $edges = $raw instanceof \Illuminate\Support\Collection ? $raw : collect($raw); - @endphp - - @if ($edges->isEmpty()) -
No dependencies found
- @else -
- @foreach ($edges->groupBy('relationship_type') as $type => $group) -
-
{{ str_replace('_', ' ', $type) }}
-
    - @foreach ($group as $edge) - @php - $isMissing = ($edge['target_type'] ?? null) === 'missing'; - $targetId = $edge['target_id'] ?? null; - $rendered = $edge['rendered_target'] ?? []; - $badgeText = is_array($rendered) ? ($rendered['badge_text'] ?? null) : null; - $linkUrl = is_array($rendered) ? ($rendered['link_url'] ?? null) : null; - - $missingTitle = 'Missing target'; - $lastKnownName = $edge['metadata']['last_known_name'] ?? null; - if (is_string($lastKnownName) && $lastKnownName !== '') { - $missingTitle .= ". Last known: {$lastKnownName}"; - } - $rawRef = $edge['metadata']['raw_ref'] ?? null; - if ($rawRef !== null) { - $encodedRef = json_encode($rawRef); - if (is_string($encodedRef) && $encodedRef !== '') { - $missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200); - } - } - - $fallbackDisplay = null; - if (is_string($lastKnownName) && $lastKnownName !== '') { - $fallbackDisplay = $lastKnownName; - } elseif (is_string($targetId) && $targetId !== '') { - $fallbackDisplay = 'ID: '.substr($targetId, 0, 6).'…'; - } else { - $fallbackDisplay = 'External reference'; - } - @endphp -
  • - @if (is_string($badgeText) && $badgeText !== '') - @if (is_string($linkUrl) && $linkUrl !== '') - {{ $badgeText }} - @else - {{ $badgeText }} - @endif - @else - {{ $fallbackDisplay }} - @endif - @if ($isMissing) - Missing - @endif -
  • - @endforeach -
-
- @endforeach -
- @endif +
diff --git a/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php b/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php index fb098cda..138065bf 100644 --- a/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php +++ b/apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php @@ -1,59 +1,5 @@
- @if ($rows === []) -
-

No evidence snapshots in this scope

-

Adjust filters or create a tenant snapshot to populate the workspace overview.

- -
- @else -
- - - - - - - - - - - - - - - @foreach ($rows as $row) - - - - - - - - - - - @endforeach - -
TenantArtifact truthFreshnessGeneratedNot collected yetRefresh recommendedNext stepAction
{{ $row['tenant_name'] }} - - {{ data_get($row, 'artifact_truth.label', 'Unknown') }} - - @if (is_string(data_get($row, 'artifact_truth.explanation')) && trim((string) data_get($row, 'artifact_truth.explanation')) !== '') -
{{ data_get($row, 'artifact_truth.explanation') }}
- @endif -
- - {{ data_get($row, 'freshness.label', 'Unknown') }} - - {{ $row['generated_at'] ?? '—' }}{{ $row['missing_dimensions'] }}{{ $row['stale_dimensions'] }}{{ $row['next_step'] ?? 'No action needed' }} - View tenant evidence -
-
- @endif + {{ $this->table }}
diff --git a/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php b/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php index 4516a16d..f26fb081 100644 --- a/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php +++ b/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php @@ -6,7 +6,7 @@ $tenant = $this->currentTenant(); - $vm = is_array($viewModel ?? null) ? $viewModel : []; + $vm = $this->viewModel(); $overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : []; @@ -14,20 +14,6 @@ $filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : []; $selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : []; - $selectedStatus = (string) ($filters['status'] ?? 'missing'); - $selectedType = (string) ($filters['type'] ?? 'all'); - $searchTerm = (string) ($filters['search'] ?? ''); - - $featureOptions = collect($featureImpacts) - ->filter(fn (mixed $impact): bool => is_array($impact) && is_string($impact['feature'] ?? null)) - ->map(fn (array $impact): string => (string) $impact['feature']) - ->filter() - ->unique() - ->sort() - ->values() - ->all(); - - $permissions = is_array($vm['permissions'] ?? null) ? $vm['permissions'] : []; $overall = $overview['overall'] ?? null; $overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $overall) : null; @@ -226,10 +212,8 @@ class="text-primary-600 hover:underline dark:text-primary-400" $selected = in_array($featureKey, $selectedFeatures, true); @endphp - + @endforeach - - @if ($selectedFeatures !== []) -
- - Clear feature filter - -
- @endif @endif
-
-
-
-
Filters
-
- Search doesn’t affect copy actions. Feature filters do. -
-
- - - Reset - -
- -
-
- - -
- -
- - -
- -
- - -
- - @if ($featureOptions !== []) -
- - -
- @endif +
+
Native permission matrix
+
+ Search doesn’t affect copy actions. Feature filters do.
- @if ($requiredTotal === 0) -
-
No permissions configured
-
- No required permissions are currently configured in config/intune_permissions.php. -
-
- @elseif ($permissions === []) -
- @if ($selectedStatus === 'missing' && $missingTotal === 0 && $selectedType === 'all' && $selectedFeatures === [] && trim($searchTerm) === '') -
All required permissions are present
-
- Switch Status to “All” if you want to review the full matrix. -
- @else -
No matches
-
- No permissions match the current filters. -
- @endif -
- @else - @php - $featuresToRender = $featureImpacts; - - if ($selectedFeatures !== []) { - $featuresToRender = collect($featureImpacts) - ->filter(fn ($impact) => is_array($impact) && in_array((string) ($impact['feature'] ?? ''), $selectedFeatures, true)) - ->values() - ->all(); - } - @endphp - - @foreach ($featuresToRender as $impact) - @php - $featureKey = is_array($impact) ? ($impact['feature'] ?? null) : null; - $featureKey = is_string($featureKey) ? $featureKey : null; - - if ($featureKey === null) { - continue; - } - - $rows = collect($permissions) - ->filter(fn ($row) => is_array($row) && in_array($featureKey, (array) ($row['features'] ?? []), true)) - ->values() - ->all(); - - if ($rows === []) { - continue; - } - @endphp - -
-
-
- {{ $featureKey }} -
-
- -
- - - - - - - - - - @foreach ($rows as $row) - @php - $key = is_array($row) ? (string) ($row['key'] ?? '') : ''; - $type = is_array($row) ? (string) ($row['type'] ?? '') : ''; - $status = is_array($row) ? (string) ($row['status'] ?? '') : ''; - $description = is_array($row) ? ($row['description'] ?? null) : null; - $description = is_string($description) ? $description : null; - - $statusSpec = BadgeRenderer::spec(BadgeDomain::TenantPermissionStatus, $status); - @endphp - - - - - - - @endforeach - -
- Permission - - Type - - Status -
-
- {{ $key }} -
- @if ($description) -
- {{ $description }} -
- @endif -
- - {{ $type === 'delegated' ? 'Delegated' : 'Application' }} - - - - {{ $statusSpec->label }} - -
-
-
- @endforeach - @endif + {{ $this->table }}
@endif
diff --git a/apps/platform/resources/views/livewire/inventory-item-dependency-edges-table.blade.php b/apps/platform/resources/views/livewire/inventory-item-dependency-edges-table.blade.php new file mode 100644 index 00000000..0689aea9 --- /dev/null +++ b/apps/platform/resources/views/livewire/inventory-item-dependency-edges-table.blade.php @@ -0,0 +1,3 @@ +
+ {{ $this->table }} +
\ No newline at end of file diff --git a/apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php b/apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php index dff42431..062e5674 100644 --- a/apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php +++ b/apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Filament\Pages\Monitoring\EvidenceOverview; use App\Filament\Resources\EvidenceSnapshotResource; use App\Models\EvidenceSnapshot; use App\Models\Tenant; @@ -10,6 +11,7 @@ use App\Support\Workspaces\WorkspaceContext; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures; uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class); @@ -122,3 +124,56 @@ ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false) ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), false); }); + +it('seeds the native entitled-tenant prefilter once and clears it through the page action', 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'); + + $snapshotA = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenantA->getKey(), + 'workspace_id' => (int) $tenantA->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Complete->value, + 'summary' => ['missing_dimensions' => 0, 'stale_dimensions' => 0], + 'generated_at' => now(), + ]); + + $snapshotB = EvidenceSnapshot::query()->create([ + 'tenant_id' => (int) $tenantB->getKey(), + 'workspace_id' => (int) $tenantB->workspace_id, + 'status' => EvidenceSnapshotStatus::Active->value, + 'completeness_state' => EvidenceCompletenessState::Partial->value, + 'summary' => ['missing_dimensions' => 1, 'stale_dimensions' => 0], + 'generated_at' => now(), + ]); + + $this->actingAs($user); + setAdminPanelContext(); + Filament::setTenant(null, true); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id); + + $component = Livewire::withQueryParams([ + 'tenant_id' => (string) $tenantB->getKey(), + 'search' => $tenantB->name, + ])->test(EvidenceOverview::class); + + $component + ->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey()) + ->assertSet('tableSearch', $tenantB->name) + ->assertCanSeeTableRecords([(string) $snapshotB->getKey()]) + ->assertCanNotSeeTableRecords([(string) $snapshotA->getKey()]); + + $component + ->callAction('clear_filters') + ->assertSet('tableFilters.tenant_id.value', null) + ->assertSet('tableSearch', '') + ->assertCanSeeTableRecords([ + (string) $snapshotA->getKey(), + (string) $snapshotB->getKey(), + ]); +}); diff --git a/apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php b/apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php index 3b94c987..9aafb456 100644 --- a/apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php +++ b/apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php @@ -40,6 +40,8 @@ Livewire::actingAs($user) ->test(EvidenceOverview::class) + ->assertCountTableRecords(1) + ->assertCanSeeTableRecords([(string) $snapshot->getKey()]) ->assertSee($tenant->name) ->assertSee('Artifact truth'); diff --git a/apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php b/apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php new file mode 100644 index 00000000..c27abadc --- /dev/null +++ b/apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php @@ -0,0 +1,131 @@ +makeCurrent(); + Filament::setTenant($tenant, true); + + test()->actingAs($user); + + return Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [ + 'inventoryItemId' => (int) $item->getKey(), + ]); +} + +it('renders dependency rows through native table filters and preserves missing-target hints', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $item = InventoryItem::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'external_id' => (string) Str::uuid(), + ]); + + $assigned = InventoryLink::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'missing', + 'target_id' => null, + 'relationship_type' => 'assigned_to', + 'metadata' => [ + 'last_known_name' => 'Assigned Target', + 'raw_ref' => ['example' => 'assigned'], + ], + ]); + + $scoped = InventoryLink::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'missing', + 'target_id' => null, + 'relationship_type' => 'scoped_by', + 'metadata' => [ + 'last_known_name' => 'Scoped Target', + 'raw_ref' => ['example' => 'scoped'], + ], + ]); + + $inbound = InventoryLink::factory()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => (string) Str::uuid(), + 'target_type' => 'inventory_item', + 'target_id' => $item->external_id, + 'relationship_type' => 'depends_on', + ]); + + $component = dependencyEdgesTableComponent($user, $tenant, $item) + ->assertTableFilterExists('direction') + ->assertTableFilterExists('relationship_type') + ->assertCanSeeTableRecords([ + (string) $assigned->getKey(), + (string) $scoped->getKey(), + (string) $inbound->getKey(), + ]) + ->assertSee('Assigned Target') + ->assertSee('Scoped Target') + ->assertSee('Missing'); + + $component + ->filterTable('direction', 'outbound') + ->assertCanSeeTableRecords([ + (string) $assigned->getKey(), + (string) $scoped->getKey(), + ]) + ->assertCanNotSeeTableRecords([(string) $inbound->getKey()]) + ->removeTableFilters() + ->filterTable('direction', 'inbound') + ->assertCanSeeTableRecords([(string) $inbound->getKey()]) + ->assertCanNotSeeTableRecords([ + (string) $assigned->getKey(), + (string) $scoped->getKey(), + ]) + ->removeTableFilters() + ->filterTable('relationship_type', 'scoped_by') + ->assertCanSeeTableRecords([(string) $scoped->getKey()]) + ->assertCanNotSeeTableRecords([ + (string) $assigned->getKey(), + (string) $inbound->getKey(), + ]) + ->removeTableFilters() + ->filterTable('direction', 'outbound') + ->filterTable('relationship_type', 'depends_on') + ->assertCountTableRecords(0) + ->assertSee('No dependencies found'); +}); + +it('returns deny-as-not-found when mounted for an item outside the current tenant scope', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $foreignItem = InventoryItem::factory()->create([ + 'tenant_id' => (int) Tenant::factory()->create()->getKey(), + 'external_id' => (string) Str::uuid(), + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + $this->actingAs($user); + + $component = Livewire::actingAs($user)->test(InventoryItemDependencyEdgesTable::class, [ + 'inventoryItemId' => (int) $foreignItem->getKey(), + ]); + + $component->assertSee('Not Found'); + + expect($component->instance())->toBeNull(); +}); diff --git a/apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php b/apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php new file mode 100644 index 00000000..1a3e85c1 --- /dev/null +++ b/apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php @@ -0,0 +1,126 @@ +set('intune_permissions.permissions', [ + [ + 'key' => 'DeviceManagementApps.Read.All', + 'type' => 'application', + 'description' => 'Backup application permission', + 'features' => ['backup'], + ], + [ + 'key' => 'Group.Read.All', + 'type' => 'delegated', + 'description' => 'Backup delegated permission', + 'features' => ['backup'], + ], + [ + 'key' => 'Reports.Read.All', + 'type' => 'application', + 'description' => 'Reporting permission', + 'features' => ['reporting'], + ], + ]); + config()->set('entra_permissions.permissions', []); + + TenantPermission::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'permission_key' => 'Group.Read.All', + 'status' => 'missing', + 'details' => ['source' => 'fixture'], + 'last_checked_at' => now(), + ]); + + TenantPermission::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'permission_key' => 'Reports.Read.All', + 'status' => 'granted', + 'details' => ['source' => 'fixture'], + 'last_checked_at' => now(), + ]); +} + +function tenantRequiredPermissionsComponent(User $user, Tenant $tenant, array $query = []) +{ + test()->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $query = array_merge([ + 'tenant' => (string) $tenant->external_id, + ], $query); + + return Livewire::withQueryParams($query)->test(TenantRequiredPermissions::class); +} + +it('uses native table filters and search while keeping summary state aligned with visible rows', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + seedTenantRequiredPermissionsFixture($tenant); + + $component = tenantRequiredPermissionsComponent($user, $tenant) + ->assertTableFilterExists('status') + ->assertTableFilterExists('type') + ->assertTableFilterExists('features') + ->assertCanSeeTableRecords([ + 'DeviceManagementApps.Read.All', + 'Group.Read.All', + ]) + ->assertCanNotSeeTableRecords(['Reports.Read.All']) + ->assertSee('Missing application permissions') + ->assertSee('Guidance'); + + $component + ->filterTable('status', 'present') + ->filterTable('type', 'application') + ->searchTable('Reports') + ->assertCountTableRecords(1) + ->assertCanSeeTableRecords(['Reports.Read.All']) + ->assertCanNotSeeTableRecords([ + 'DeviceManagementApps.Read.All', + 'Group.Read.All', + ]); + + $viewModel = $component->instance()->viewModel(); + + expect($viewModel['overview']['counts'])->toBe([ + 'missing_application' => 0, + 'missing_delegated' => 0, + 'present' => 1, + 'error' => 0, + ]) + ->and(array_column($viewModel['permissions'], 'key'))->toBe(['Reports.Read.All']) + ->and($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All'); +}); + +it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + seedTenantRequiredPermissionsFixture($tenant); + + $component = tenantRequiredPermissionsComponent($user, $tenant) + ->set('tableFilters.features.values', ['backup']) + ->assertSet('tableFilters.features.values', ['backup']); + + $viewModel = $component->instance()->viewModel(); + + expect($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All') + ->and($viewModel['copy']['delegated'])->toBe('Group.Read.All'); + + $component + ->searchTable('no-such-permission') + ->assertCountTableRecords(0) + ->assertSee('No matches') + ->assertTableEmptyStateActionsExistInOrder(['clear_filters']); +}); diff --git a/apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php b/apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php index 8cd68a91..b054e6d8 100644 --- a/apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php +++ b/apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php @@ -29,7 +29,9 @@ 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php', 'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php', 'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php', + 'app/Filament/Pages/TenantRequiredPermissions.php', 'app/Filament/Pages/InventoryCoverage.php', + 'app/Filament/Pages/Monitoring/EvidenceOverview.php', 'app/Filament/System/Pages/Directory/Tenants.php', 'app/Filament/System/Pages/Directory/Workspaces.php', 'app/Filament/System/Pages/Ops/Runs.php', @@ -39,6 +41,7 @@ 'app/Filament/System/Pages/RepairWorkspaceOwners.php', 'app/Filament/Widgets/Dashboard/RecentDriftFindings.php', 'app/Filament/Widgets/Dashboard/RecentOperations.php', + 'app/Livewire/InventoryItemDependencyEdgesTable.php', 'app/Livewire/BackupSetPolicyPickerTable.php', 'app/Livewire/EntraGroupCachePickerTable.php', 'app/Livewire/SettingsCatalogSettingsTable.php', @@ -81,7 +84,9 @@ 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('], 'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('], 'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php' => ['->emptyStateHeading('], + 'app/Filament/Pages/TenantRequiredPermissions.php' => ['->emptyStateHeading('], 'app/Filament/Pages/InventoryCoverage.php' => ['->emptyStateHeading('], + 'app/Filament/Pages/Monitoring/EvidenceOverview.php' => ['->emptyStateHeading('], 'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('], 'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('], 'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('], @@ -91,6 +96,7 @@ 'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('], 'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('], 'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('], + 'app/Livewire/InventoryItemDependencyEdgesTable.php' => ['->emptyStateHeading('], 'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('], 'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('], 'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('], @@ -134,6 +140,8 @@ 'app/Filament/Resources/EntraGroupResource.php', 'app/Filament/Resources/OperationRunResource.php', 'app/Filament/Resources/BaselineSnapshotResource.php', + 'app/Filament/Pages/TenantRequiredPermissions.php', + 'app/Filament/Pages/Monitoring/EvidenceOverview.php', 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php', ]; @@ -310,7 +318,9 @@ 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php', 'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php', 'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php', + 'app/Filament/Pages/TenantRequiredPermissions.php', 'app/Filament/Pages/InventoryCoverage.php', + 'app/Filament/Pages/Monitoring/EvidenceOverview.php', 'app/Filament/System/Pages/Directory/Tenants.php', 'app/Filament/System/Pages/Directory/Workspaces.php', 'app/Filament/System/Pages/Ops/Runs.php', @@ -320,6 +330,7 @@ 'app/Filament/System/Pages/RepairWorkspaceOwners.php', 'app/Filament/Widgets/Dashboard/RecentDriftFindings.php', 'app/Filament/Widgets/Dashboard/RecentOperations.php', + 'app/Livewire/InventoryItemDependencyEdgesTable.php', 'app/Livewire/BackupSetPolicyPickerTable.php', 'app/Livewire/EntraGroupCachePickerTable.php', 'app/Livewire/SettingsCatalogSettingsTable.php', @@ -337,6 +348,85 @@ expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing)); }); + it('keeps spec 196 surfaces on native table contracts without faux controls or hand-built primary tables', function (): void { + $requiredPatterns = [ + 'app/Filament/Pages/TenantRequiredPermissions.php' => [ + 'implements HasTable', + 'InteractsWithTable', + ], + 'app/Filament/Pages/Monitoring/EvidenceOverview.php' => [ + 'implements HasTable', + 'InteractsWithTable', + ], + 'app/Livewire/InventoryItemDependencyEdgesTable.php' => [ + 'extends TableComponent', + ], + 'resources/views/filament/components/dependency-edges.blade.php' => [ + 'inventory-item-dependency-edges-table', + ], + 'resources/views/filament/pages/tenant-required-permissions.blade.php' => [ + '$this->table', + 'data-testid="technical-details"', + ], + 'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [ + '$this->table', + ], + ]; + + $forbiddenPatterns = [ + 'resources/views/filament/components/dependency-edges.blade.php' => [ + '
[ + 'wire:model.live="status"', + 'wire:model.live="type"', + 'wire:model.live="features"', + 'wire:model.live.debounce.500ms="search"', + '', + ], + 'resources/views/filament/pages/monitoring/evidence-overview.blade.php' => [ + '
', + ], + ]; + + $missing = []; + $unexpected = []; + + foreach ($requiredPatterns 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})"; + } + } + } + + foreach ($forbiddenPatterns as $relativePath => $patterns) { + $contents = file_get_contents(base_path($relativePath)); + + if (! is_string($contents)) { + continue; + } + + foreach ($patterns as $pattern) { + if (str_contains($contents, $pattern)) { + $unexpected[] = "{$relativePath} ({$pattern})"; + } + } + } + + expect($missing)->toBeEmpty('Missing native table contract patterns: '.implode(', ', $missing)) + ->and($unexpected)->toBeEmpty('Unexpected faux-control or hand-built table patterns remain: '.implode(', ', $unexpected)); + }); + it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void { $patternByPath = [ 'app/Filament/Resources/TenantResource.php' => [ diff --git a/apps/platform/tests/Feature/InventoryItemDependenciesTest.php b/apps/platform/tests/Feature/InventoryItemDependenciesTest.php index e010de2e..30b4c9de 100644 --- a/apps/platform/tests/Feature/InventoryItemDependenciesTest.php +++ b/apps/platform/tests/Feature/InventoryItemDependenciesTest.php @@ -41,7 +41,7 @@ ->assertSee('Last known: Ghost Target'); }); -it('direction filter limits to outbound or inbound', function () { +it('renders native dependency controls in place instead of a GET apply workflow', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); @@ -51,34 +51,48 @@ 'external_id' => (string) Str::uuid(), ]); + $inboundSource = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'external_id' => (string) Str::uuid(), + 'display_name' => 'Inbound Source', + ]); + // Outbound only InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', 'source_id' => $item->external_id, - 'target_type' => 'foundation_object', - 'target_id' => (string) Str::uuid(), + 'target_type' => 'missing', + 'target_id' => null, 'relationship_type' => 'assigned_to', + 'metadata' => [ + 'last_known_name' => 'Assigned Target', + ], ]); // Inbound only InventoryLink::factory()->create([ 'tenant_id' => $tenant->getKey(), 'source_type' => 'inventory_item', - 'source_id' => (string) Str::uuid(), + 'source_id' => $inboundSource->external_id, 'target_type' => 'inventory_item', 'target_id' => $item->external_id, 'relationship_type' => 'depends_on', ]); - $urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; - $this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found'); + $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; - $urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound'; - $this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found'); + $this->get($url) + ->assertOk() + ->assertSee('Direction') + ->assertSee('Inbound') + ->assertSee('Outbound') + ->assertSee('Relationship') + ->assertSee('Assigned Target') + ->assertDontSee('No dependencies found'); }); -it('relationship filter limits edges by type', function () { +it('ignores legacy relationship query state while preserving visible target safety', function () { [$user, $tenant] = createUserWithTenant(); $this->actingAs($user); @@ -115,7 +129,7 @@ $this->get($url) ->assertOk() ->assertSee('Scoped Target') - ->assertDontSee('Assigned Target'); + ->assertSee('Assigned Target'); }); it('does not show edges from other tenants (tenant isolation)', function () { diff --git a/apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php b/apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php index 89bfbeea..cde7594e 100644 --- a/apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php +++ b/apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use App\Filament\Pages\TenantRequiredPermissions; use App\Models\Tenant; use App\Models\TenantPermission; use App\Models\User; use App\Models\Workspace; use App\Models\WorkspaceMembership; use App\Support\Workspaces\WorkspaceContext; +use Livewire\Livewire; it('keeps the route tenant authoritative when tenant-like query values are present', function (): void { [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -54,7 +56,7 @@ $response ->assertSee($tenant->getFilamentName()) - ->assertSee('data-permission-key="Tenant.Read.All"', false); + ->assertSee('Tenant.Read.All'); }); it('returns 404 when the current workspace no longer matches the tenant route scope', function (): void { @@ -86,3 +88,52 @@ ->get('/admin/tenants/'.$tenant->external_id.'/required-permissions') ->assertNotFound(); }); + +it('seeds native table state from deeplink filters without letting query values redefine the route tenant', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $otherTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Ignored Query Tenant', + 'external_id' => 'ignored-query-tenant', + ]); + + config()->set('intune_permissions.permissions', [ + [ + 'key' => 'Tenant.Read.All', + 'type' => 'application', + 'description' => 'Tenant read permission', + 'features' => ['backup'], + ], + ]); + config()->set('entra_permissions.permissions', []); + + TenantPermission::query()->create([ + 'tenant_id' => (int) $tenant->getKey(), + 'permission_key' => 'Tenant.Read.All', + 'status' => 'granted', + 'details' => ['source' => 'db'], + 'last_checked_at' => now(), + ]); + + $this->actingAs($user); + setAdminPanelContext(); + session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); + + $component = Livewire::withQueryParams([ + 'tenant' => $tenant->external_id, + 'tenant_id' => (string) $otherTenant->getKey(), + 'status' => 'present', + 'type' => 'application', + 'features' => ['backup'], + 'search' => 'Tenant', + ])->test(TenantRequiredPermissions::class); + + $component + ->assertSet('tableFilters.status.value', 'present') + ->assertSet('tableFilters.type.value', 'application') + ->assertSet('tableFilters.features.values', ['backup']) + ->assertSet('tableSearch', 'Tenant'); + + expect($component->instance()->currentTenant()?->is($tenant))->toBeTrue(); +}); diff --git a/specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml b/specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml index 85004b94..c491a3f7 100644 --- a/specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml +++ b/specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml @@ -377,11 +377,18 @@ x-spec-196-notes: - route tenant stays authoritative on required-permissions - evidence overview only exposes entitled tenant rows - dependency rendering remains tenant-isolated and DB-only + - cleaned surfaces remain read-only and introduce no new remote runtime calls at render time + - native Filament or Livewire state remains the primary contract, with no new wrapper or presenter layer introduced only to translate pseudo-native state + - required-permissions summary counts, freshness, guidance visibility, and copy payload remain derived from the same normalized filter state as the visible rows + - evidence overview preserves a meaningful empty state, clear-filter affordance when scoped, and one workspace-safe inspect path per authorized row - query values may seed initial state but not stay the primary contract nonGoals: - runtime API exposure - new persistence + - new polling behavior - new provider or route families + - new global or on-demand asset requirements + - shared wrapper or presenter framework - global context shell redesign - monitoring page-state architecture rewrite - audit log selected-record or inspect duality cleanup diff --git a/specs/196-hard-filament-nativity-cleanup/plan.md b/specs/196-hard-filament-nativity-cleanup/plan.md index 0de5092d..4b79cfe1 100644 --- a/specs/196-hard-filament-nativity-cleanup/plan.md +++ b/specs/196-hard-filament-nativity-cleanup/plan.md @@ -64,7 +64,7 @@ ## Phase 0 Research - Keep `TenantRequiredPermissions` and `EvidenceOverview` on derived data and current services instead of adding new projections, tables, or materialized helper models. - Replace inventory dependency GET-form controls with an embedded Livewire `TableComponent` because the surface is detail-context and not a true relation manager or a standalone page. - Treat query parameters as one-time seed or deeplink inputs only; after mount, native page or component state owns filter interaction. -- No additional low-risk same-class hit is confirmed in planning; default implementation scope stays at the three named core surfaces unless implementation audit finds one trivial match that does not widen scope. +- No additional same-class extra hit is confirmed in planning; default implementation scope stays fixed at the three named core surfaces unless the setup audit records a candidate that passes every FR-196-015 admission check without widening architecture or adding new persistence, routes, or shared abstractions. - Extend existing focused tests and the current Filament table guard where possible instead of introducing a new browser-only verification layer. ## Phase 1 Design @@ -169,7 +169,8 @@ ### Phase 0.5 - Establish shared test and guard scaffolding Changes: - Create the new focused test entry points for the dependency table component and required-permissions page table. -- Extend shared guard coverage for new native page-table expectations and faux-control regressions. +- Record the pre-implementation scope gate: unless the setup audit documents an FR-196-015 pass, scope is frozen to the three named core surfaces and no optional extra hit may begin. +- Extend shared guard coverage for new native page-table expectations, faux-control regressions, and no-new-wrapper drift. - Add shared regression coverage for mount-only query seeding versus authoritative scope on required permissions and evidence overview. Tests: @@ -245,6 +246,7 @@ ### Phase D - Verification, guard alignment, and explicit scope stop - Run focused Sail verification for the modified feature, RBAC, and guard tests. - Record the release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md`, including cleaned surfaces, deferred themes, optional extra hits, and touched follow-up specs. - Document any optional additional same-class hit only if it was truly included; otherwise record that no extra candidate was confirmed. +- Verify the final implementation still satisfies the non-functional constraints: DB-only rendering, no new remote calls, no new persistence, and no asset or provider drift. - Stop immediately if implementation reaches shared micro-UI family, monitoring-state, or shell-context architecture. Tests: @@ -293,4 +295,4 @@ ## Implementation Order Recommendation 2. Replace inventory dependencies second, with the focused story tests landing before the implementation changes. 3. Convert `TenantRequiredPermissions` third, again extending the story tests before code changes. 4. Convert `EvidenceOverview` fourth, with its focused page and derived-state tests updated before the refactor lands. -5. Run the final focused verification pack, formatting, and release close-out last, and only then consider whether any optional same-class extra hit truly qualifies. \ No newline at end of file +5. Run the final focused verification pack, formatting, and release close-out last, and record whether the setup audit admitted any optional same-class extra hit. No new optional extra hit may enter scope after implementation has started. \ No newline at end of file diff --git a/specs/196-hard-filament-nativity-cleanup/quickstart.md b/specs/196-hard-filament-nativity-cleanup/quickstart.md index 57634041..7baf16fd 100644 --- a/specs/196-hard-filament-nativity-cleanup/quickstart.md +++ b/specs/196-hard-filament-nativity-cleanup/quickstart.md @@ -19,6 +19,7 @@ ### 1. Prepare shared test and guard scaffolding Do: - create the new focused surface-test entry points before story implementation starts +- perform the setup audit that decides whether any optional same-class extra hit passes every FR-196-015 admission check before any extra hit begins; otherwise lock scope to the three named surfaces - add the shared guard expectations for new native page-table and faux-control regressions - add the shared mount-only query-seeding regression coverage that later story work depends on @@ -124,7 +125,7 @@ ### 7. Record the release close-out in this quickstart When implementation is complete, update this file with a short close-out note that records: - which surfaces were actually cleaned -- whether any optional same-class extra hit was included or explicitly rejected +- whether any optional same-class extra hit was included or explicitly rejected, and if included, which FR-196-015 admission checks it satisfied - which related themes stayed out of scope and were deferred - which follow-up specs or artifacts were touched @@ -151,15 +152,26 @@ ## Suggested Test Pack ## Manual Smoke Checklist -1. Open an inventory item detail page and confirm dependency direction and relationship changes happen without a foreign apply-and-reload workflow. -2. Open tenant required permissions and confirm the filter surface feels native, while summary counts, guidance, and copy flows remain correct. -3. Open evidence overview and confirm the table behaves like a native Filament report with clear empty state and row inspect behavior. -4. Confirm no cleaned surface leaks scope through query manipulation. -5. Confirm no implementation expanded into monitoring-state, shell, or shared micro-UI redesign work. +1. Open an inventory item detail page and confirm current record context stays visible, dependency direction and relationship changes happen in place without a foreign apply-and-reload workflow, missing-target markers remain visible where applicable, and empty-state copy still explains the no-results case. +2. Open tenant required permissions and confirm current tenant scope and active filter state remain visible, the filter surface feels native, summary counts and freshness stay consistent with the visible rows, guidance remains available, and copy flows still use the same filtered state. +3. Open evidence overview and confirm workspace scope and any active entitled-tenant filter remain visible, the table behaves like a native Filament report, artifact truth, freshness, and next-step context remain visible by default, `Clear filters` behaves correctly, and each authorized row still has one workspace-safe inspect path. +4. Confirm no cleaned surface leaks scope, rows, counts, or drilldown targets through query manipulation. +5. Confirm no implementation expanded into monitoring-state, shell, shared micro-UI redesign work, new wrapper layers, or new persistence created only to support the native controls. ## Deployment Notes - No migration is expected. +- No polling change is expected. - No provider registration change is expected. - No new assets are expected. -- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged. \ No newline at end of file +- Existing `cd apps/platform && php artisan filament:assets` deployment handling remains sufficient and unchanged. + +## Release Close-Out + +- Cleaned surfaces: inventory item dependency edges now run through the embedded `InventoryItemDependencyEdgesTable` component; tenant required permissions now uses one page-owned native table contract; evidence overview now uses one page-owned native workspace table. +- Optional same-class extra hit decision: rejected. No additional nativity-bypass candidate passed every FR-196-015 admission check during setup, so scope remained frozen to the three named surfaces. +- Deferred themes kept out of scope: monitoring page-state architecture, global shell or context redesign, shared micro-UI or wrapper abstractions, verification viewer families, and any new persistence or asset work. +- Follow-up artifacts touched: this quickstart note, the Spec 196 task ledger, and the existing logical contract remained aligned without widening consumer scope. +- Focused Sail verification pack: passed on 2026-04-14 with 45 tests and 177 assertions across the Spec 196 feature, guard, and unit coverage set. +- Integrated-browser smoke sign-off: passed on `http://localhost` against tenant `19000000-0000-4000-8000-000000000191`, including an inventory detail fixture (`inventory-items/383`) and evidence fixture (`evidence/20`). Verified in-place dependency filters with visible active filter chips and missing-target hints, native required-permissions search plus technical-details matrix continuity, and evidence overview tenant prefilter plus `Clear filters` behavior with workspace-safe drilldown links. +- Browser-log note: the integrated-browser session still contains old historical 419 and aborted-request noise from prior sessions, but no new Spec 196 surface-specific JavaScript failure blocked the smoke flow above. \ No newline at end of file diff --git a/specs/196-hard-filament-nativity-cleanup/spec.md b/specs/196-hard-filament-nativity-cleanup/spec.md index 6620e123..d4b2a537 100644 --- a/specs/196-hard-filament-nativity-cleanup/spec.md +++ b/specs/196-hard-filament-nativity-cleanup/spec.md @@ -177,12 +177,19 @@ ### Functional Requirements - **FR-196-012**: Evidence overview MUST provide one consistent inspect or open model for authorized rows and MUST preserve the current workspace-safe drilldown into tenant evidence. - **FR-196-013**: Evidence overview MUST remove manual page-body query and Blade wiring that exists only because the report table is hand-built, while preserving entitled tenant prefilter behavior. - **FR-196-014**: Evidence overview MUST preserve workspace boundary enforcement, entitled-tenant filtering, and deny-as-not-found behavior for users outside the workspace boundary. -- **FR-196-015**: Any additional cleanup hit included under this spec MUST share the same unnecessary nativity bypass, remain low to medium complexity, add no new product semantics, and avoid shared-family, shell, monitoring-state, and special-visualization work. +- **FR-196-015**: Any additional cleanup hit included under this spec MUST pass all of the following admission checks before implementation starts on that hit: it removes the same confirmed nativity-bypass problem class as the three core surfaces (primary GET-form controls, request-driven page-body state, or a hand-built primary table imitating native Filament behavior); it stays read-only and preserves current scope semantics; it can be completed by modifying existing files only, or by adding at most one narrow sibling view or component file with no new route, persistence, enum, or shared abstraction; it does not enter shared-family, shell, monitoring-state, diff, verification-report, or special-visualization work; and it is explicitly recorded in release close-out. If any admission check fails, the candidate is out of scope. - **FR-196-016**: Any discovered related surface that crosses into shared detail micro-UI, monitoring state, context shell, verification report, diff or settings viewer, restore preview or result layouts, or other declared non-goal families MUST be documented and deferred instead of partially refactored here. -- **FR-196-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, or cross-page UI abstraction whose main purpose is to hide the same non-native contract. -- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before, with no loss of empty-state meaning, next-step clarity, scope signals, or inspect navigation. +- **FR-196-017**: This cleanup MUST NOT introduce a new wrapper microframework, presenter layer, cross-page UI abstraction, or service whose main purpose is to translate bespoke pseudo-native page state into native Filament primitives. If native Filament or Livewire page state can express the behavior directly, direct state wins. +- **FR-196-018**: Each cleaned surface MUST remain operatorically at least as clear as before. This requirement is satisfied only when the cleaned surface preserves all of the following, in the same primary surface where the operator is already working: inventory dependencies still shows current record context, direction and relationship scope labels, missing-target markers, meaningful empty-state copy, and safe inspect links; tenant required permissions still shows current tenant scope, active filter state, overall counts, freshness, guidance visibility, and copy payload behavior derived from the same normalized filter state; evidence overview still shows workspace scope, active entitled-tenant filter state, artifact truth, freshness, next-step context, clear-filter affordance, and one workspace-safe inspect path for authorized rows. - **FR-196-019**: Release close-out MUST list which surfaces were actually cleaned, which optional same-class low-risk hits were included, which related themes remained out of scope, and which follow-up specs were touched. +### Non-Functional Requirements + +- **NFR-196-001**: Render-time behavior for inventory dependencies, tenant required permissions, and evidence overview MUST remain DB-only and MUST NOT introduce new Microsoft Graph calls, external HTTP requests, or other remote runtime dependencies. +- **NFR-196-002**: This cleanup MUST NOT add new persistence artifacts, including tables, persisted UI-state mirrors, materialized helper projections, or helper models whose only purpose is to support the new native controls. +- **NFR-196-003**: This cleanup MUST NOT add polling, provider registration changes, or new global or on-demand asset requirements. Existing `bootstrap/providers.php` registration and current `filament:assets` deployment handling remain unchanged. +- **NFR-196-004**: Implementation MUST stay inside the current Filament v5 and Livewire v4 page layer and current derived services unless a touched existing service needs a narrow adapter to keep one authoritative normalized filter state. + ## UI Action Matrix *(mandatory when Filament is changed)* | Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | @@ -208,6 +215,9 @@ ### Measurable Outcomes - **SC-196-004**: Release validation finds zero primary plain HTML control surfaces on the three core pages whose only purpose is to imitate native admin controls. - **SC-196-005**: Deeplink and prefilter behaviors continue to work for the targeted routes without allowing unauthorized tenant scope changes or cross-tenant row leakage. - **SC-196-006**: Final close-out documentation explicitly records completed surfaces, deferred related themes, and any optional extra hits that were admitted under the shared rule. +- **SC-196-007**: Inventory dependencies preserves current-record context, direction and relationship labels, missing-target markers, safe inspect links, and meaningful empty-state copy after native state adoption. +- **SC-196-008**: Tenant required permissions preserves visible active filter state, counts, freshness, guidance, and copy payload behavior that stay internally consistent for the same tenant and the same normalized filter state. +- **SC-196-009**: Evidence overview preserves visible workspace scope signals, entitled-tenant clear-filter behavior, artifact truth, freshness, next-step context, and one workspace-safe inspect path for every authorized row. ## Assumptions diff --git a/specs/196-hard-filament-nativity-cleanup/tasks.md b/specs/196-hard-filament-nativity-cleanup/tasks.md index 7ebae864..b24d3ea7 100644 --- a/specs/196-hard-filament-nativity-cleanup/tasks.md +++ b/specs/196-hard-filament-nativity-cleanup/tasks.md @@ -7,7 +7,7 @@ # Tasks: Hard Filament Nativity Cleanup **Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/196-hard-filament-nativity-cleanup/` **Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/filament-nativity-cleanup.logical.openapi.yaml` -**Tests**: Runtime behavior changes on existing Filament v5 / Livewire v4 operator surfaces require Pest feature, Livewire, RBAC, unit, and guard coverage. This task list adds or extends only the focused tests needed for the three in-scope surfaces. +**Tests**: Runtime behavior changes on existing Filament v5 / Livewire v4 operator surfaces require Pest feature, Livewire, RBAC, unit, and guard coverage. This task list adds or extends only the focused tests needed for the three in-scope surfaces and their DB-only, no-wrapper, and scope-safety constraints. **Operations**: This cleanup does not introduce new queued work or `OperationRun` flows. Existing linked follow-up paths remain unchanged. **RBAC**: Tenant-context, route-tenant, workspace-membership, and entitled-tenant boundaries remain authoritative. Non-members stay `404`, and no new destructive action is added. **UI Naming**: Keep existing operator terms stable: `Dependencies`, `Direction`, `Relationship`, `Required permissions`, `Status`, `Type`, `Search`, `Evidence overview`, `Artifact truth`, `Freshness`, and `Next step`. @@ -18,8 +18,8 @@ ## Phase 1: Setup (Shared Review Inputs) **Purpose**: Confirm the exact implementation entry points, native reference patterns, and focused regression baselines before editing the three in-scope surfaces. -- [ ] T001 Audit the current nativity-bypass entry points and native reference implementations in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/resources/views/filament/components/dependency-edges.blade.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php` -- [ ] T002 [P] Audit the focused regression baselines in `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` +- [X] T001 Audit the current nativity-bypass entry points and native reference implementations in `apps/platform/app/Filament/Resources/InventoryItemResource.php`, `apps/platform/resources/views/filament/components/dependency-edges.blade.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php`, `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php`, `apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `apps/platform/app/Filament/Pages/InventoryCoverage.php`, and `apps/platform/app/Livewire/BackupSetPolicyPickerTable.php`, and record whether any optional extra candidate passes every FR-196-015 admission check; otherwise freeze scope to the three named surfaces +- [X] T002 [P] Audit the focused regression baselines in `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` --- @@ -29,11 +29,11 @@ ## Phase 2: Foundational (Blocking Prerequisites) **CRITICAL**: No user story work should begin until this phase is complete. -- [ ] T003 [P] Create the new Spec 196 surface-test entry points in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` and `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` -- [ ] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables and faux-control regressions in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` -- [ ] T005 [P] Add shared regression coverage for mount-only query seeding versus authoritative scope in `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` +- [X] T003 [P] Create the new Spec 196 surface-test entry points in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` and `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` +- [X] T004 [P] Review and, if newly applicable, extend shared native-table guard coverage for Spec 196 page-owned tables, faux-control regressions, and no-new-wrapper drift in `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` +- [X] T005 [P] Add shared regression coverage for mount-only query seeding versus authoritative scope in `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` and `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` -**Checkpoint**: The shared Spec 196 test harness is in place, and later surface work can prove native state ownership without reopening scope or guard assumptions. +**Checkpoint**: The shared Spec 196 test harness and scope gate are in place, and later surface work can prove native state ownership without reopening scope or guard assumptions. --- @@ -47,13 +47,13 @@ ### Tests for User Story 1 > **NOTE**: Write these tests first and confirm they fail before implementation. -- [ ] T006 [P] [US1] Extend `apps/platform/tests/Feature/InventoryItemDependenciesTest.php` with native component-state expectations for direction changes, relationship narrowing, empty states, and preserved target safety -- [ ] T007 [P] [US1] Add Livewire table-component coverage in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` for mount state, filter updates, missing-target rendering, and tenant isolation +- [X] T006 [P] [US1] Extend `apps/platform/tests/Feature/InventoryItemDependenciesTest.php` with native component-state expectations for direction changes, relationship narrowing, empty states, and preserved target safety +- [X] T007 [P] [US1] Add Livewire table-component coverage in `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php` for mount state, filter updates, missing-target rendering, and tenant isolation ### Implementation for User Story 1 -- [ ] T008 [US1] Create `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php` as an embedded Filament `TableComponent` that owns direction and relationship state and queries rows through the current dependency services -- [ ] T009 [US1] Update `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/resources/views/filament/components/dependency-edges.blade.php` to mount the embedded table component and remove the GET-form / `request()`-driven control contract while preserving target links, badges, and missing-target hints +- [X] T008 [US1] Create `apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php` as an embedded Filament `TableComponent` that owns direction and relationship state and queries rows through the current dependency services +- [X] T009 [US1] Update `apps/platform/app/Filament/Resources/InventoryItemResource.php` and `apps/platform/resources/views/filament/components/dependency-edges.blade.php` to mount the embedded table component and remove the GET-form / `request()`-driven control contract while preserving target links, badges, and missing-target hints **Checkpoint**: User Story 1 is complete when inventory detail keeps the same dependency meaning and target safety without switching operators into a foreign apply-and-reload workflow. @@ -69,14 +69,14 @@ ### Tests for User Story 2 > **NOTE**: Write these tests first and confirm they fail before implementation. -- [ ] T010 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` for route-tenant authority, query-seeded status/type/search/features state, and ignored foreign-tenant query values -- [ ] T011 [P] [US2] Add native page-table coverage in `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` for filter updates, search, summary consistency, guidance visibility, copy payload continuity, and no-results states -- [ ] T012 [P] [US2] Keep filter-normalization, overall-status, feature-impact, freshness, and copy-payload invariants aligned in `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` +- [X] T010 [P] [US2] Extend `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php` for route-tenant authority, query-seeded status/type/search/features state, and ignored foreign-tenant query values +- [X] T011 [P] [US2] Add native page-table coverage in `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php` for filter updates, search, summary consistency, guidance visibility, copy payload continuity, and no-results states +- [X] T012 [P] [US2] Keep filter-normalization, overall-status, feature-impact, freshness, and copy-payload invariants aligned in `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` ### Implementation for User Story 2 -- [ ] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding -- [ ] T014 [US2] Align `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php` and, if needed, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so summary counts, freshness, feature impacts, guidance, and copy payloads are derived from the same normalized native table state +- [X] T013 [US2] Convert `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` to `HasTable` / `InteractsWithTable` with native filters, native search, and mount-only query seeding +- [X] T014 [US2] Align `apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php` and, if needed, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so summary counts, freshness, feature impacts, guidance, and copy payloads are derived from the same normalized native table state **Checkpoint**: User Story 2 is complete when required permissions behaves like one native Filament page without losing tenant authority, summary clarity, or follow-up guidance. @@ -92,13 +92,13 @@ ### Tests for User Story 3 > **NOTE**: Write these tests first and confirm they fail before implementation. -- [ ] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, entitled-tenant seed and clear behavior, workspace-safe row drilldown, empty states, and deny-as-not-found enforcement -- [ ] T016 [P] [US3] Extend `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` and, if newly applicable, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for DB-only derived-row rendering and the new page-owned native table contract +- [X] T015 [P] [US3] Extend `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php` for native table rendering, native search behavior, visible workspace scope and active entitled-tenant filter state, artifact-truth and freshness and next-step row fields, entitled-tenant seed and clear behavior, workspace-safe row drilldown, clear-filter behavior, empty states, and deny-as-not-found enforcement +- [X] T016 [P] [US3] Extend `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php` and, if newly applicable, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php` for DB-only derived-row rendering and the new page-owned native table contract ### Implementation for User Story 3 -- [ ] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks, native filter and search state, entitled-tenant query seeding, and one inspect model -- [ ] T018 [US3] Replace the hand-built report table in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php` with a native table wrapper that preserves the clear-filter affordance and current drilldown copy +- [X] T017 [US3] Convert `apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php` to `HasTable` / `InteractsWithTable` with derived row callbacks that preserve visible workspace scope, active entitled-tenant filter state, artifact-truth, freshness, and next-step row context, native filter and search state, entitled-tenant query seeding, clear-filter behavior, and one inspect model +- [X] T018 [US3] Replace the hand-built report table in `apps/platform/resources/views/filament/pages/monitoring/evidence-overview.blade.php` with a native table wrapper that preserves the clear-filter affordance and current drilldown copy **Checkpoint**: User Story 3 is complete when evidence overview reads like one native workspace review table without leaking unauthorized tenant scope or losing the current drilldown path. @@ -108,11 +108,11 @@ ## Phase 6: Polish & Cross-Cutting Verification **Purpose**: Run the focused verification pack, format the touched files, and record the final bounded scope outcome for Spec 196. -- [ ] T019 Run the focused Spec 196 Sail verification pack from `specs/196-hard-filament-nativity-cleanup/quickstart.md` against `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` -- [ ] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in the changed files under `apps/platform/app/`, `apps/platform/resources/views/filament/`, and `apps/platform/tests/` -- [ ] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces and capture any sign-off notes needed for release close-out -- [ ] T022 Record the Spec 196 release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md` with the final cleaned surfaces, any optional same-class extra hit decision, deferred themes, and touched follow-up specs -- [ ] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, and non-goals in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope +- [X] T019 Run the focused Spec 196 Sail verification pack from `specs/196-hard-filament-nativity-cleanup/quickstart.md` against `apps/platform/tests/Feature/InventoryItemDependenciesTest.php`, `apps/platform/tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php`, `apps/platform/tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php`, `apps/platform/tests/Feature/Filament/TenantRequiredPermissionsPageTest.php`, `apps/platform/tests/Feature/Evidence/EvidenceOverviewPageTest.php`, `apps/platform/tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php`, `apps/platform/tests/Feature/Guards/FilamentTableStandardsGuardTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFilteringTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsOverallStatusTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php`, `apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php`, and `apps/platform/tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php`, and confirm the pack still proves DB-only rendering, no-wrapper drift protection, and scope-safety invariants +- [X] T020 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` and resolve formatting issues in the changed files under `apps/platform/app/`, `apps/platform/resources/views/filament/`, and `apps/platform/tests/` +- [X] T021 Execute the manual smoke checklist in `specs/196-hard-filament-nativity-cleanup/quickstart.md` across the three cleaned surfaces, explicitly verifying current-context visibility, active filter or scope signals, empty-state meaning, guidance or next-step clarity, and inspect navigation, and capture any sign-off notes needed for release close-out +- [X] T022 Record the Spec 196 release close-out in `specs/196-hard-filament-nativity-cleanup/quickstart.md` with the final cleaned surfaces, any optional same-class extra hit decision, deferred themes, and touched follow-up specs +- [X] T023 Verify the final close-out note in `specs/196-hard-filament-nativity-cleanup/quickstart.md` and the contract-modeled consumers, invariants, non-goals, and non-functional constraints in `specs/196-hard-filament-nativity-cleanup/contracts/filament-nativity-cleanup.logical.openapi.yaml` remain aligned with the implemented scope ---