Spec 190: tighten baseline compare matrix scanability (#222)
## Summary - tighten the baseline compare matrix working surface with active filter scope summaries and clearer visible-set disclosure - improve matrix scanability with a sticky subject column, calmer attention-first cell styling, and Filament form-based filter controls - replace the misleading perpetual refresh loading state with a passive auto-refresh note and add focused regression coverage ## Testing - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php` ## Notes - this PR only contains the Spec 190 implementation changes on `190-baseline-compare-matrix` - follow-up spec drafting for high-density operator mode was intentionally left out of this PR Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #222
This commit is contained in:
parent
eca19819d1
commit
65e10a2020
@ -23,12 +23,19 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Concerns\InteractsWithForms;
|
||||
use Filament\Forms\Contracts\HasForms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
|
||||
use Filament\Resources\Pages\Page;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class BaselineCompareMatrix extends Page
|
||||
class BaselineCompareMatrix extends Page implements HasForms
|
||||
{
|
||||
use InteractsWithForms;
|
||||
use InteractsWithRecord;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
@ -83,6 +90,82 @@ public function mount(int|string $record): void
|
||||
$this->record = $this->resolveRecord($record);
|
||||
$this->hydrateFiltersFromRequest();
|
||||
$this->refreshMatrix();
|
||||
$this->form->fill($this->filterFormState());
|
||||
}
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Grid::make([
|
||||
'default' => 1,
|
||||
'xl' => 2,
|
||||
])
|
||||
->schema([
|
||||
Grid::make([
|
||||
'default' => 1,
|
||||
'lg' => 5,
|
||||
])
|
||||
->schema([
|
||||
CheckboxList::make('selectedPolicyTypes')
|
||||
->label('Policy types')
|
||||
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
|
||||
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
|
||||
? 'Policy type filters appear after a usable reference snapshot is available.'
|
||||
: null)
|
||||
->extraFieldWrapperAttributes([
|
||||
'data-testid' => 'matrix-policy-type-filter',
|
||||
])
|
||||
->columns(1)
|
||||
->columnSpan([
|
||||
'lg' => 2,
|
||||
])
|
||||
->live(),
|
||||
CheckboxList::make('selectedStates')
|
||||
->label('Technical states')
|
||||
->options(fn (): array => $this->matrixOptions('stateOptions'))
|
||||
->columnSpan([
|
||||
'lg' => 2,
|
||||
])
|
||||
->columns(1)
|
||||
->live(),
|
||||
CheckboxList::make('selectedSeverities')
|
||||
->label('Severity')
|
||||
->options(fn (): array => $this->matrixOptions('severityOptions'))
|
||||
->columns(1)
|
||||
->live(),
|
||||
])
|
||||
->columnSpan([
|
||||
'xl' => 1,
|
||||
]),
|
||||
Grid::make([
|
||||
'default' => 1,
|
||||
'md' => 2,
|
||||
'xl' => 1,
|
||||
])
|
||||
->schema([
|
||||
Select::make('tenantSort')
|
||||
->label('Tenant sort')
|
||||
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
||||
->default('tenant_name')
|
||||
->native(false)
|
||||
->live()
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
|
||||
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
|
||||
Select::make('subjectSort')
|
||||
->label('Subject sort')
|
||||
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
|
||||
->default('deviation_breadth')
|
||||
->native(false)
|
||||
->live()
|
||||
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
|
||||
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
|
||||
])
|
||||
->columnSpan([
|
||||
'xl' => 1,
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
@ -168,6 +251,11 @@ public function refreshMatrix(): void
|
||||
]);
|
||||
}
|
||||
|
||||
public function pollMatrix(): void
|
||||
{
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function tenantCompareUrl(int $tenantId, ?string $subjectKey = null): ?string
|
||||
{
|
||||
$tenant = $this->tenant($tenantId);
|
||||
@ -254,6 +342,40 @@ public function updatedFocusedSubjectKey(): void
|
||||
$this->refreshMatrix();
|
||||
}
|
||||
|
||||
public function activeFilterCount(): int
|
||||
{
|
||||
return count($this->selectedPolicyTypes)
|
||||
+ count($this->selectedStates)
|
||||
+ count($this->selectedSeverities)
|
||||
+ ($this->focusedSubjectKey !== null ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
public function activeFilterSummary(): array
|
||||
{
|
||||
$summary = [];
|
||||
|
||||
if ($this->selectedPolicyTypes !== []) {
|
||||
$summary['Policy types'] = count($this->selectedPolicyTypes);
|
||||
}
|
||||
|
||||
if ($this->selectedStates !== []) {
|
||||
$summary['Technical states'] = count($this->selectedStates);
|
||||
}
|
||||
|
||||
if ($this->selectedSeverities !== []) {
|
||||
$summary['Severity'] = count($this->selectedSeverities);
|
||||
}
|
||||
|
||||
if ($this->focusedSubjectKey !== null) {
|
||||
$summary['Focused subject'] = $this->focusedSubjectKey;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@ -283,6 +405,30 @@ private function hydrateFiltersFromRequest(): void
|
||||
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function filterFormState(): array
|
||||
{
|
||||
return [
|
||||
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||
'selectedStates' => $this->selectedStates,
|
||||
'selectedSeverities' => $this->selectedSeverities,
|
||||
'tenantSort' => $this->tenantSort,
|
||||
'subjectSort' => $this->subjectSort,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function matrixOptions(string $key): array
|
||||
{
|
||||
$options = $this->matrix[$key] ?? null;
|
||||
|
||||
return is_array($options) ? $options : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<x-filament::page>
|
||||
@if (($hasActiveRuns ?? false) === true)
|
||||
<div wire:poll.5s="refreshMatrix"></div>
|
||||
<div wire:poll.5s="pollMatrix"></div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
@ -19,6 +19,9 @@
|
||||
$currentFilters = is_array($currentFilters ?? null) ? $currentFilters : [];
|
||||
$referenceReady = ($reference['referenceState'] ?? null) === 'ready';
|
||||
$matrixSourceNavigation = is_array($navigationContext ?? null) ? $navigationContext : null;
|
||||
$activeFilterCount = $this->activeFilterCount();
|
||||
$activeFilterSummary = $this->activeFilterSummary();
|
||||
$hiddenAssignedTenantCount = max(0, (int) ($reference['assignedTenantCount'] ?? 0) - (int) ($reference['visibleTenantCount'] ?? 0));
|
||||
|
||||
$stateBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixState, $value);
|
||||
$freshnessBadge = static fn (mixed $value) => \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::BaselineCompareMatrixFreshness, $value);
|
||||
@ -55,6 +58,12 @@
|
||||
Snapshot #{{ (int) $reference['referenceSnapshotId'] }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
@if ($hiddenAssignedTenantCount > 0)
|
||||
<x-filament::badge color="info" icon="heroicon-m-eye-slash" size="sm">
|
||||
{{ $hiddenAssignedTenantCount }} hidden by access scope
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
@ -75,6 +84,12 @@
|
||||
Reference reason: {{ \Illuminate\Support\Str::headline(str_replace('.', ' ', (string) $reference['referenceReasonCode'])) }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($hiddenAssignedTenantCount > 0)
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Showing only the visible assigned set for your current access scope. Hidden tenants are excluded from summaries, rows, and drilldowns.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -115,165 +130,139 @@
|
||||
Narrow the matrix by policy type, technical state, severity, or one focused subject. Only the visible tenant set contributes to the rendered counts, rows, and drilldowns.
|
||||
</x-slot>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]" data-testid="baseline-compare-matrix-filters">
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Policy types</div>
|
||||
<div class="space-y-4" data-testid="baseline-compare-matrix-filters">
|
||||
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/50" data-testid="matrix-active-filters">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-1 min-w-0">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Current matrix scope</div>
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
@if ($activeFilterCount === 0)
|
||||
No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.
|
||||
@else
|
||||
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }} are narrowing the matrix before you scan drift and follow-up links.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($policyTypeOptions !== [])
|
||||
<div class="space-y-2">
|
||||
@foreach ($policyTypeOptions as $value => $label)
|
||||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
value="{{ $value }}"
|
||||
wire:model.live="selectedPolicyTypes"
|
||||
data-testid="matrix-filter-policy-type-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
||||
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
||||
/>
|
||||
<span>{{ $label }}</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
<x-filament::badge :color="$activeFilterCount === 0 ? 'gray' : 'info'" icon="heroicon-m-funnel" size="sm">
|
||||
@if ($activeFilterCount === 0)
|
||||
All visible results
|
||||
@else
|
||||
{{ $activeFilterCount }} active {{ \Illuminate\Support\Str::plural('filter', $activeFilterCount) }}
|
||||
@endif
|
||||
</x-filament::badge>
|
||||
|
||||
@if ($hiddenAssignedTenantCount > 0)
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Visible-set only
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($activeFilterSummary !== [])
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($activeFilterSummary as $label => $value)
|
||||
<x-filament::badge color="info" size="sm">
|
||||
{{ $label }}: {{ $value }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Policy type filters appear after a usable reference snapshot is available.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Technical states</div>
|
||||
<form wire:submit.prevent="refreshMatrix">
|
||||
{{ $this->form }}
|
||||
</form>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($stateOptions as $value => $label)
|
||||
@php
|
||||
$spec = $stateBadge($value);
|
||||
@endphp
|
||||
|
||||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
value="{{ $value }}"
|
||||
wire:model.live="selectedStates"
|
||||
data-testid="matrix-filter-state-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
||||
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
||||
/>
|
||||
<span class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
||||
{{ $label }}
|
||||
</x-filament::badge>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Severity</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($severityOptions as $value => $label)
|
||||
@php
|
||||
$spec = $severityBadge($value);
|
||||
@endphp
|
||||
|
||||
<label class="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
value="{{ $value }}"
|
||||
wire:model.live="selectedSeverities"
|
||||
data-testid="matrix-filter-severity-{{ \Illuminate\Support\Str::slug($value, '-') }}"
|
||||
class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900"
|
||||
/>
|
||||
<span class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge :color="$spec->color" :icon="$spec->icon" size="sm">
|
||||
{{ $label }}
|
||||
</x-filament::badge>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-1">
|
||||
<label class="space-y-2 text-sm">
|
||||
<span class="font-semibold text-gray-950 dark:text-white">Tenant sort</span>
|
||||
<select wire:model.live="tenantSort" data-testid="matrix-tenant-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
||||
@foreach ($tenantSortOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="space-y-2 text-sm">
|
||||
<span class="font-semibold text-gray-950 dark:text-white">Subject sort</span>
|
||||
<select wire:model.live="subjectSort" data-testid="matrix-subject-sort" class="w-full rounded-lg border-gray-300 text-sm shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-white">
|
||||
@foreach ($subjectSortOptions as $value => $label)
|
||||
<option value="{{ $value }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">Focused subject</div>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex-1 rounded-xl border border-dashed border-gray-300 bg-gray-50 px-4 py-2.5 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span class="font-semibold text-gray-950 dark:text-white">Focused subject</span>
|
||||
|
||||
@if (filled($currentFilters['subject_key'] ?? null))
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<x-filament::badge color="info" icon="heroicon-m-funnel" size="sm">
|
||||
{{ $currentFilters['subject_key'] }}
|
||||
</x-filament::badge>
|
||||
|
||||
<x-filament::link :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
|
||||
<x-filament::button tag="a" :href="$this->clearSubjectFocusUrl()" color="gray" size="sm">
|
||||
Clear subject focus
|
||||
</x-filament::link>
|
||||
</div>
|
||||
</x-filament::button>
|
||||
@else
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Focus a single row from the matrix when you want a subject-first drilldown.</p>
|
||||
<span class="text-gray-500 dark:text-gray-400">None set yet. Use Focus subject from a row when you want a subject-first drilldown.</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<x-filament::link :href="\App\Filament\Resources\BaselineProfileResource::compareMatrixUrl($profile)" color="gray" size="sm">
|
||||
<div class="flex flex-wrap items-center gap-3 lg:shrink-0">
|
||||
@if ($activeFilterCount > 0)
|
||||
<x-filament::button tag="a" :href="\App\Filament\Resources\BaselineProfileResource::compareMatrixUrl($profile)" color="gray" size="sm">
|
||||
Clear all filters
|
||||
</x-filament::link>
|
||||
</x-filament::button>
|
||||
@else
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
No filter reset needed
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament-actions::modals />
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
<div class="grid gap-4 xl:grid-cols-3">
|
||||
<x-filament::section heading="State legend">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="relative" data-testid="baseline-compare-matrix-results">
|
||||
@if (($hasActiveRuns ?? false) === true)
|
||||
<div class="mb-2 flex justify-end" data-testid="baseline-compare-matrix-auto-refresh-note">
|
||||
<div class="flex items-center gap-2 rounded-full border border-gray-200 bg-white/95 px-3 py-1.5 text-xs shadow-sm dark:border-gray-800 dark:bg-gray-900/95">
|
||||
<x-filament::badge color="info" icon="heroicon-m-arrow-path" size="sm">
|
||||
Auto-refresh every 5 seconds while compare runs are queued or running.
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-6">
|
||||
<x-filament::section heading="Matrix signal legend">
|
||||
<div class="grid gap-3 xl:grid-cols-3">
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">State legend</div>
|
||||
|
||||
@foreach ($stateLegend as $item)
|
||||
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
||||
{{ $item['label'] }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Freshness legend</div>
|
||||
|
||||
<x-filament::section heading="Freshness legend">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($freshnessLegend as $item)
|
||||
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
||||
{{ $item['label'] }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50/70 px-3 py-2 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Trust legend</div>
|
||||
|
||||
<x-filament::section heading="Trust legend">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($trustLegend as $item)
|
||||
<x-filament::badge :color="$item['color']" :icon="$item['icon']" size="sm">
|
||||
{{ $item['label'] }}
|
||||
</x-filament::badge>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($emptyState !== null)
|
||||
<x-filament::section>
|
||||
@ -371,15 +360,15 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
|
||||
|
||||
<x-filament::section heading="Subject-by-tenant matrix">
|
||||
<x-slot name="description">
|
||||
Row click is intentionally disabled. Use the explicit subject, tenant, finding, and run links inside the matrix cells.
|
||||
Row click is intentionally disabled. The subject column stays pinned while you scan across visible tenants.
|
||||
</x-slot>
|
||||
|
||||
<div class="overflow-x-auto" data-testid="baseline-compare-matrix-grid">
|
||||
<div class="overflow-x-auto rounded-2xl" data-testid="baseline-compare-matrix-grid">
|
||||
<div class="min-w-[72rem] overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-800">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-800">
|
||||
<table class="min-w-full border-separate border-spacing-0">
|
||||
<thead class="bg-gray-50 dark:bg-gray-950/70">
|
||||
<tr>
|
||||
<th class="w-80 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<th class="sticky left-0 z-20 w-[26rem] border-r border-gray-200 bg-gray-50 px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-800 dark:bg-gray-950/70 dark:text-gray-400">
|
||||
Baseline subject
|
||||
</th>
|
||||
|
||||
@ -387,7 +376,7 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
|
||||
@php
|
||||
$freshnessSpec = $freshnessBadge($tenantSummary['freshnessState'] ?? null);
|
||||
@endphp
|
||||
<th class="min-w-64 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<th class="min-w-72 px-4 py-3 text-left align-top text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold normal-case text-gray-950 dark:text-white">{{ $tenantSummary['tenantName'] }}</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@ -401,17 +390,21 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-800 dark:bg-gray-900/60">
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
|
||||
@foreach ($rows as $row)
|
||||
@php
|
||||
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
||||
$cells = is_array($row['cells'] ?? null) ? $row['cells'] : [];
|
||||
$subjectSeveritySpec = filled($subject['maxSeverity'] ?? null) ? $severityBadge($subject['maxSeverity']) : null;
|
||||
$subjectTrustSpec = $trustBadge($subject['trustLevel'] ?? null);
|
||||
$rowKey = (string) ($subject['subjectKey'] ?? 'subject-'.$loop->index);
|
||||
$rowSurfaceClasses = $loop->even
|
||||
? 'bg-gray-50/70 dark:bg-gray-950/20'
|
||||
: 'bg-white dark:bg-gray-900/60';
|
||||
@endphp
|
||||
|
||||
<tr data-testid="baseline-compare-matrix-row">
|
||||
<td class="px-4 py-4 align-top">
|
||||
<tr wire:key="baseline-compare-matrix-row-{{ $rowKey }}" data-testid="baseline-compare-matrix-row" class="group transition-colors hover:bg-primary-50/30 dark:hover:bg-primary-950/10 {{ $rowSurfaceClasses }}">
|
||||
<td class="sticky left-0 z-10 border-r border-gray-200 px-4 py-4 align-top dark:border-gray-800 {{ $rowSurfaceClasses }}">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
@ -462,6 +455,27 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
|
||||
$cellStateSpec = $stateBadge($cell['state'] ?? null);
|
||||
$cellTrustSpec = $trustBadge($cell['trustLevel'] ?? null);
|
||||
$cellSeveritySpec = filled($cell['severity'] ?? null) ? $severityBadge($cell['severity']) : null;
|
||||
$cellState = (string) ($cell['state'] ?? '');
|
||||
$cellSeverity = is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null;
|
||||
$cellNeedsAttention = in_array($cellState, ['differ', 'missing', 'ambiguous'], true)
|
||||
|| in_array($cellSeverity, ['critical', 'high'], true);
|
||||
$cellNeedsRefresh = in_array($cellState, ['stale_result', 'not_compared'], true);
|
||||
$cellLooksHealthy = $cellState === 'match' && $cellNeedsAttention === false && $cellNeedsRefresh === false;
|
||||
$cellSurfaceClasses = $cellNeedsAttention
|
||||
? 'border-danger-300 bg-danger-50/80 ring-1 ring-danger-200/70 dark:border-danger-900/70 dark:bg-danger-950/15 dark:ring-danger-900/40'
|
||||
: ($cellNeedsRefresh
|
||||
? 'border-warning-300 bg-warning-50/80 ring-1 ring-warning-200/70 dark:border-warning-900/70 dark:bg-warning-950/15 dark:ring-warning-900/40'
|
||||
: ($cellLooksHealthy
|
||||
? 'border-success-200 bg-success-50/70 dark:border-success-900/60 dark:bg-success-950/10'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-950/40'));
|
||||
$cellPriorityLabel = $cellNeedsAttention
|
||||
? 'Needs attention'
|
||||
: ($cellNeedsRefresh ? 'Refresh recommended' : ($cellLooksHealthy ? 'Aligned' : 'Review'));
|
||||
$cellPriorityClasses = $cellNeedsAttention
|
||||
? 'bg-danger-100 text-danger-700 dark:bg-danger-950/40 dark:text-danger-300'
|
||||
: ($cellNeedsRefresh
|
||||
? 'bg-warning-100 text-warning-700 dark:bg-warning-950/40 dark:text-warning-300'
|
||||
: 'bg-success-100 text-success-700 dark:bg-success-950/40 dark:text-success-300');
|
||||
$subjectKey = $subject['subjectKey'] ?? ($cell['subjectKey'] ?? null);
|
||||
$tenantId = (int) ($cell['tenantId'] ?? 0);
|
||||
$tenantCompareUrl = $tenantId > 0 ? $this->tenantCompareUrl($tenantId, $subjectKey) : null;
|
||||
@ -473,15 +487,13 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
|
||||
: null;
|
||||
@endphp
|
||||
|
||||
<td class="px-4 py-4 align-top">
|
||||
<div class="space-y-3 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950/40">
|
||||
<td wire:key="baseline-compare-matrix-cell-{{ $rowKey }}-{{ $tenantId > 0 ? $tenantId : $loop->index }}" class="px-4 py-4 align-top">
|
||||
<div class="space-y-3 rounded-xl border p-3 transition-colors group-hover:border-primary-200 group-hover:bg-white dark:group-hover:border-primary-900 dark:group-hover:bg-gray-950/60 {{ $cellSurfaceClasses }}">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$cellStateSpec->color" :icon="$cellStateSpec->icon" size="sm">
|
||||
{{ $cellStateSpec->label }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
|
||||
{{ $cellTrustSpec->label }}
|
||||
</x-filament::badge>
|
||||
@if ($cellSeveritySpec)
|
||||
<x-filament::badge :color="$cellSeveritySpec->color" :icon="$cellSeveritySpec->icon" size="sm">
|
||||
{{ $cellSeveritySpec->label }}
|
||||
@ -489,6 +501,17 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-semibold uppercase tracking-wide {{ $cellPriorityClasses }}">
|
||||
{{ $cellPriorityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$cellTrustSpec->color" :icon="$cellTrustSpec->icon" size="sm">
|
||||
{{ $cellTrustSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
@if (filled($cell['reasonCode'] ?? null))
|
||||
<div>
|
||||
@ -535,4 +558,6 @@ class="mt-0.5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 da
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::page>
|
||||
|
||||
@ -48,21 +48,34 @@
|
||||
->assertNoJavaScriptErrors()
|
||||
->waitForText('Visible-set baseline')
|
||||
->assertSee('Reference overview')
|
||||
->assertSee('No narrowing filters are active')
|
||||
->assertSee('Subject-by-tenant matrix')
|
||||
->assertSee('WiFi Corp Profile')
|
||||
->assertSee('Windows Compliance')
|
||||
->assertSee('Open finding');
|
||||
|
||||
$page->script(<<<'JS'
|
||||
const input = document.querySelector('[data-testid="matrix-filter-state-differ"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.click();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
const input = Array.from(document.querySelectorAll('input[type="checkbox"]')).find((element) => {
|
||||
if (element.getAttribute('aria-label') === 'Drift detected') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const label = element.closest('label');
|
||||
|
||||
return label instanceof HTMLLabelElement && label.innerText.includes('Drift detected');
|
||||
});
|
||||
|
||||
if (! (input instanceof HTMLInputElement)) {
|
||||
throw new Error('Drift detected checkbox not found.');
|
||||
}
|
||||
|
||||
input.click();
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
JS);
|
||||
|
||||
$page
|
||||
->wait(1)
|
||||
->waitForText('Open finding')
|
||||
->assertDontSee('Windows Compliance')
|
||||
->click('Open finding')
|
||||
|
||||
@ -39,14 +39,21 @@
|
||||
->assertOk()
|
||||
->assertSee('Visible-set baseline')
|
||||
->assertSee('Reference overview')
|
||||
->assertSee('fi-fo-checkbox-list', false)
|
||||
->assertSee('fi-fo-select', false)
|
||||
->assertSee('State legend')
|
||||
->assertSee('Tenant summaries')
|
||||
->assertSee('Subject-by-tenant matrix')
|
||||
->assertSee('No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.')
|
||||
->assertSee('1 hidden by access scope')
|
||||
->assertSee('WiFi Corp Profile')
|
||||
->assertSee((string) $fixture['visibleTenant']->name)
|
||||
->assertSee((string) $fixture['visibleTenantTwo']->name)
|
||||
->assertSee('Needs attention')
|
||||
->assertSee('Open finding')
|
||||
->assertSee('Open tenant compare');
|
||||
->assertSee('Open tenant compare')
|
||||
->assertSee('data-testid="matrix-active-filters"', false)
|
||||
->assertSee('sticky left-0', false);
|
||||
});
|
||||
|
||||
it('keeps query-backed filters and subject focus on the matrix route and drilldown links', function (): void {
|
||||
@ -82,6 +89,9 @@
|
||||
])
|
||||
->actingAs($fixture['user'])
|
||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||
->assertSee('4 active filters')
|
||||
->assertSee('Policy types: 1')
|
||||
->assertSee('Focused subject: wifi-corp-profile')
|
||||
->assertSee('Clear subject focus')
|
||||
->assertDontSee('Windows Compliance');
|
||||
|
||||
@ -139,6 +149,31 @@
|
||||
->assertSee('No visible assigned tenants');
|
||||
});
|
||||
|
||||
it('renders a passive auto-refresh note instead of a perpetual loading state while compare runs remain active', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
$this->makeBaselineCompareMatrixRun(
|
||||
$fixture['visibleTenant'],
|
||||
$fixture['profile'],
|
||||
$fixture['snapshot'],
|
||||
attributes: [
|
||||
'status' => \App\Support\OperationRunStatus::Queued->value,
|
||||
'outcome' => \App\Support\OperationRunOutcome::Pending->value,
|
||||
'completed_at' => null,
|
||||
'started_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||
|
||||
$this->withSession($session)
|
||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||
->assertOk()
|
||||
->assertSee('Auto-refresh every 5 seconds while compare runs are queued or running.')
|
||||
->assertSee('wire:poll.5s="pollMatrix"', false)
|
||||
->assertDontSee('Refreshing matrix');
|
||||
});
|
||||
|
||||
it('renders an empty state when no rows match the current filters', function (): void {
|
||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||
|
||||
|
||||
@ -139,6 +139,7 @@ ## Phase 7: Polish & Cross-Cutting Concerns
|
||||
- [X] T036 [P] Add no-ad-hoc-badge and no-diagnostic-warning guard coverage for matrix state surfaces in `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php` and `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`
|
||||
- [X] T037 [P] Add browser smoke coverage for matrix render, one filter interaction, and one drilldown or compare affordance in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||
- [X] T038 [P] Review `Verb + Object`, `visible-set only`, and `simulation only` copy in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Filament/Resources/BaselineProfileResource/Pages/ViewBaselineProfile.php`, and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||
- [X] T041 [P] Tighten matrix scanability with active-filter scope summaries, visible-set scope disclosure, non-blocking refresh feedback, sticky subject-column treatment, and focused UI regression coverage in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||
- [X] T039 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` using `specs/190-baseline-compare-matrix/quickstart.md`
|
||||
- [ ] T040 Run the focused verification pack from `specs/190-baseline-compare-matrix/quickstart.md` against `apps/platform/tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareStatsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareFindingsTest.php`, `apps/platform/tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php`, `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php`, `apps/platform/tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, `apps/platform/tests/Feature/Guards/NoAdHocStatusBadgesTest.php`, `apps/platform/tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user