feat: add baseline compare operator modes (#224)
## Summary - add adaptive baseline compare presentation modes with `auto`, `dense`, and `compact` route handling on the existing matrix page - compress support surfaces with staged filters, grouped legends, last-updated and passive refresh cues, compact single-tenant results, and dense multi-tenant scan rendering - extend the matrix builder plus Pest and browser smoke coverage for visible-set-only compact and dense workflows ## Filament / Laravel notes - Livewire v4 compliance preserved; no legacy Livewire v3 patterns introduced - provider registration is unchanged; no `bootstrap/providers.php` changes were needed for this feature - no globally searchable resources were changed by this branch - no destructive actions were added; the existing compare action remains simulation-only and non-destructive - asset strategy is unchanged; no new Filament assets were introduced ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` - `80` tests passed with `673` assertions - integrated browser smoke run on `http://localhost/admin/baseline-profiles/20/compare-matrix` ## Scope - Spec 191 implementation - spec contract updates in `spec.md`, `tasks.md`, and the logical OpenAPI contract Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #224
This commit is contained in:
parent
f7bbea2623
commit
74210bac2e
@ -23,7 +23,6 @@
|
|||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\CheckboxList;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
@ -48,6 +47,8 @@ class BaselineCompareMatrix extends Page implements HasForms
|
|||||||
|
|
||||||
protected string $view = 'filament.pages.baseline-compare-matrix';
|
protected string $view = 'filament.pages.baseline-compare-matrix';
|
||||||
|
|
||||||
|
public string $requestedMode = 'auto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
@ -69,6 +70,25 @@ class BaselineCompareMatrix extends Page implements HasForms
|
|||||||
|
|
||||||
public ?string $focusedSubjectKey = null;
|
public ?string $focusedSubjectKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $draftSelectedPolicyTypes = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $draftSelectedStates = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $draftSelectedSeverities = [];
|
||||||
|
|
||||||
|
public string $draftTenantSort = 'tenant_name';
|
||||||
|
|
||||||
|
public string $draftSubjectSort = 'deviation_breadth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var array<string, mixed>
|
* @var array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -107,33 +127,40 @@ public function form(Schema $schema): Schema
|
|||||||
'lg' => 5,
|
'lg' => 5,
|
||||||
])
|
])
|
||||||
->schema([
|
->schema([
|
||||||
CheckboxList::make('selectedPolicyTypes')
|
Select::make('draftSelectedPolicyTypes')
|
||||||
->label('Policy types')
|
->label('Policy types')
|
||||||
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
|
->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->preload()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All policy types')
|
||||||
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
|
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
|
||||||
? 'Policy type filters appear after a usable reference snapshot is available.'
|
? 'Policy type filters appear after a usable reference snapshot is available.'
|
||||||
: null)
|
: null)
|
||||||
->extraFieldWrapperAttributes([
|
->extraFieldWrapperAttributes([
|
||||||
'data-testid' => 'matrix-policy-type-filter',
|
'data-testid' => 'matrix-policy-type-filter',
|
||||||
])
|
])
|
||||||
->columns(1)
|
|
||||||
->columnSpan([
|
->columnSpan([
|
||||||
'lg' => 2,
|
'lg' => 2,
|
||||||
])
|
]),
|
||||||
->live(),
|
Select::make('draftSelectedStates')
|
||||||
CheckboxList::make('selectedStates')
|
|
||||||
->label('Technical states')
|
->label('Technical states')
|
||||||
->options(fn (): array => $this->matrixOptions('stateOptions'))
|
->options(fn (): array => $this->matrixOptions('stateOptions'))
|
||||||
|
->multiple()
|
||||||
|
->searchable()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All technical states')
|
||||||
->columnSpan([
|
->columnSpan([
|
||||||
'lg' => 2,
|
'lg' => 2,
|
||||||
])
|
]),
|
||||||
->columns(1)
|
Select::make('draftSelectedSeverities')
|
||||||
->live(),
|
|
||||||
CheckboxList::make('selectedSeverities')
|
|
||||||
->label('Severity')
|
->label('Severity')
|
||||||
->options(fn (): array => $this->matrixOptions('severityOptions'))
|
->options(fn (): array => $this->matrixOptions('severityOptions'))
|
||||||
->columns(1)
|
->multiple()
|
||||||
->live(),
|
->searchable()
|
||||||
|
->native(false)
|
||||||
|
->placeholder('All severities'),
|
||||||
])
|
])
|
||||||
->columnSpan([
|
->columnSpan([
|
||||||
'xl' => 1,
|
'xl' => 1,
|
||||||
@ -144,20 +171,18 @@ public function form(Schema $schema): Schema
|
|||||||
'xl' => 1,
|
'xl' => 1,
|
||||||
])
|
])
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('tenantSort')
|
Select::make('draftTenantSort')
|
||||||
->label('Tenant sort')
|
->label('Tenant sort')
|
||||||
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
->options(fn (): array => $this->matrixOptions('tenantSortOptions'))
|
||||||
->default('tenant_name')
|
->default('tenant_name')
|
||||||
->native(false)
|
->native(false)
|
||||||
->live()
|
|
||||||
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
|
->extraFieldWrapperAttributes(['data-testid' => 'matrix-tenant-sort'])
|
||||||
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
|
->extraInputAttributes(['data-testid' => 'matrix-tenant-sort']),
|
||||||
Select::make('subjectSort')
|
Select::make('draftSubjectSort')
|
||||||
->label('Subject sort')
|
->label('Subject sort')
|
||||||
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
|
->options(fn (): array => $this->matrixOptions('subjectSortOptions'))
|
||||||
->default('deviation_breadth')
|
->default('deviation_breadth')
|
||||||
->native(false)
|
->native(false)
|
||||||
->live()
|
|
||||||
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
|
->extraFieldWrapperAttributes(['data-testid' => 'matrix-subject-sort'])
|
||||||
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
|
->extraInputAttributes(['data-testid' => 'matrix-subject-sort']),
|
||||||
])
|
])
|
||||||
@ -232,6 +257,34 @@ protected function getHeaderActions(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function applyFilters(): void
|
||||||
|
{
|
||||||
|
$this->selectedPolicyTypes = $this->normalizeQueryList($this->draftSelectedPolicyTypes);
|
||||||
|
$this->selectedStates = $this->normalizeQueryList($this->draftSelectedStates);
|
||||||
|
$this->selectedSeverities = $this->normalizeQueryList($this->draftSelectedSeverities);
|
||||||
|
$this->tenantSort = $this->normalizeTenantSort($this->draftTenantSort);
|
||||||
|
$this->subjectSort = $this->normalizeSubjectSort($this->draftSubjectSort);
|
||||||
|
|
||||||
|
$this->redirect($this->filterUrl(), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetFilters(): void
|
||||||
|
{
|
||||||
|
$this->selectedPolicyTypes = [];
|
||||||
|
$this->selectedStates = [];
|
||||||
|
$this->selectedSeverities = [];
|
||||||
|
$this->tenantSort = 'tenant_name';
|
||||||
|
$this->subjectSort = 'deviation_breadth';
|
||||||
|
$this->focusedSubjectKey = null;
|
||||||
|
$this->draftSelectedPolicyTypes = [];
|
||||||
|
$this->draftSelectedStates = [];
|
||||||
|
$this->draftSelectedSeverities = [];
|
||||||
|
$this->draftTenantSort = 'tenant_name';
|
||||||
|
$this->draftSubjectSort = 'deviation_breadth';
|
||||||
|
|
||||||
|
$this->redirect($this->filterUrl(), navigate: true);
|
||||||
|
}
|
||||||
|
|
||||||
public function refreshMatrix(): void
|
public function refreshMatrix(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -307,41 +360,18 @@ public function clearSubjectFocusUrl(): string
|
|||||||
]), panel: 'admin');
|
]), panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function modeUrl(string $mode): string
|
||||||
|
{
|
||||||
|
return $this->filterUrl([
|
||||||
|
'mode' => $this->normalizeRequestedMode($mode),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function filterUrl(array $overrides = []): string
|
public function filterUrl(array $overrides = []): string
|
||||||
{
|
{
|
||||||
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
|
return static::getUrl($this->routeParameters($overrides), panel: 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedSelectedPolicyTypes(): void
|
|
||||||
{
|
|
||||||
$this->refreshMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSelectedStates(): void
|
|
||||||
{
|
|
||||||
$this->refreshMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSelectedSeverities(): void
|
|
||||||
{
|
|
||||||
$this->refreshMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedTenantSort(): void
|
|
||||||
{
|
|
||||||
$this->refreshMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedSubjectSort(): void
|
|
||||||
{
|
|
||||||
$this->refreshMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedFocusedSubjectKey(): void
|
|
||||||
{
|
|
||||||
$this->refreshMatrix();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function activeFilterCount(): int
|
public function activeFilterCount(): int
|
||||||
{
|
{
|
||||||
return count($this->selectedPolicyTypes)
|
return count($this->selectedPolicyTypes)
|
||||||
@ -350,6 +380,25 @@ public function activeFilterCount(): int
|
|||||||
+ ($this->focusedSubjectKey !== null ? 1 : 0);
|
+ ($this->focusedSubjectKey !== null ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasStagedFilterChanges(): bool
|
||||||
|
{
|
||||||
|
return $this->draftFilterState() !== $this->appliedFilterState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canUseCompactMode(): bool
|
||||||
|
{
|
||||||
|
return $this->visibleTenantCount() <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function presentationModeLabel(string $mode): string
|
||||||
|
{
|
||||||
|
return match ($mode) {
|
||||||
|
'dense' => 'Dense mode',
|
||||||
|
'compact' => 'Compact mode',
|
||||||
|
default => 'Auto mode',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, int|string>
|
* @return array<string, int|string>
|
||||||
*/
|
*/
|
||||||
@ -376,6 +425,36 @@ public function activeFilterSummary(): array
|
|||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, int|string>
|
||||||
|
*/
|
||||||
|
public function stagedFilterSummary(): array
|
||||||
|
{
|
||||||
|
$summary = [];
|
||||||
|
|
||||||
|
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
|
||||||
|
$summary['Policy types'] = count($this->draftSelectedPolicyTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftSelectedStates !== $this->selectedStates) {
|
||||||
|
$summary['Technical states'] = count($this->draftSelectedStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftSelectedSeverities !== $this->selectedSeverities) {
|
||||||
|
$summary['Severity'] = count($this->draftSelectedSeverities);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftTenantSort !== $this->tenantSort) {
|
||||||
|
$summary['Tenant sort'] = $this->draftTenantSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->draftSubjectSort !== $this->subjectSort) {
|
||||||
|
$summary['Subject sort'] = $this->draftSubjectSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@ -384,6 +463,7 @@ protected function getViewData(): array
|
|||||||
return array_merge($this->matrix, [
|
return array_merge($this->matrix, [
|
||||||
'profile' => $this->getRecord(),
|
'profile' => $this->getRecord(),
|
||||||
'currentFilters' => [
|
'currentFilters' => [
|
||||||
|
'mode' => $this->requestedMode,
|
||||||
'policy_type' => $this->selectedPolicyTypes,
|
'policy_type' => $this->selectedPolicyTypes,
|
||||||
'state' => $this->selectedStates,
|
'state' => $this->selectedStates,
|
||||||
'severity' => $this->selectedSeverities,
|
'severity' => $this->selectedSeverities,
|
||||||
@ -391,18 +471,32 @@ protected function getViewData(): array
|
|||||||
'subject_sort' => $this->subjectSort,
|
'subject_sort' => $this->subjectSort,
|
||||||
'subject_key' => $this->focusedSubjectKey,
|
'subject_key' => $this->focusedSubjectKey,
|
||||||
],
|
],
|
||||||
|
'draftFilters' => [
|
||||||
|
'policy_type' => $this->draftSelectedPolicyTypes,
|
||||||
|
'state' => $this->draftSelectedStates,
|
||||||
|
'severity' => $this->draftSelectedSeverities,
|
||||||
|
'tenant_sort' => $this->draftTenantSort,
|
||||||
|
'subject_sort' => $this->draftSubjectSort,
|
||||||
|
],
|
||||||
|
'presentationState' => $this->presentationState(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function hydrateFiltersFromRequest(): void
|
private function hydrateFiltersFromRequest(): void
|
||||||
{
|
{
|
||||||
|
$this->requestedMode = $this->normalizeRequestedMode(request()->query('mode', 'auto'));
|
||||||
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
|
$this->selectedPolicyTypes = $this->normalizeQueryList(request()->query('policy_type', []));
|
||||||
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
|
$this->selectedStates = $this->normalizeQueryList(request()->query('state', []));
|
||||||
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
|
$this->selectedSeverities = $this->normalizeQueryList(request()->query('severity', []));
|
||||||
$this->tenantSort = is_string(request()->query('tenant_sort')) ? (string) request()->query('tenant_sort') : 'tenant_name';
|
$this->tenantSort = $this->normalizeTenantSort(request()->query('tenant_sort', 'tenant_name'));
|
||||||
$this->subjectSort = is_string(request()->query('subject_sort')) ? (string) request()->query('subject_sort') : 'deviation_breadth';
|
$this->subjectSort = $this->normalizeSubjectSort(request()->query('subject_sort', 'deviation_breadth'));
|
||||||
$subjectKey = request()->query('subject_key');
|
$subjectKey = request()->query('subject_key');
|
||||||
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
$this->focusedSubjectKey = is_string($subjectKey) && trim($subjectKey) !== '' ? trim($subjectKey) : null;
|
||||||
|
$this->draftSelectedPolicyTypes = $this->selectedPolicyTypes;
|
||||||
|
$this->draftSelectedStates = $this->selectedStates;
|
||||||
|
$this->draftSelectedSeverities = $this->selectedSeverities;
|
||||||
|
$this->draftTenantSort = $this->tenantSort;
|
||||||
|
$this->draftSubjectSort = $this->subjectSort;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -411,11 +505,11 @@ private function hydrateFiltersFromRequest(): void
|
|||||||
private function filterFormState(): array
|
private function filterFormState(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
'draftSelectedPolicyTypes' => $this->draftSelectedPolicyTypes,
|
||||||
'selectedStates' => $this->selectedStates,
|
'draftSelectedStates' => $this->draftSelectedStates,
|
||||||
'selectedSeverities' => $this->selectedSeverities,
|
'draftSelectedSeverities' => $this->draftSelectedSeverities,
|
||||||
'tenantSort' => $this->tenantSort,
|
'draftTenantSort' => $this->draftTenantSort,
|
||||||
'subjectSort' => $this->subjectSort,
|
'draftSubjectSort' => $this->draftSubjectSort,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -429,6 +523,46 @@ private function matrixOptions(string $key): array
|
|||||||
return is_array($options) ? $options : [];
|
return is_array($options) ? $options : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* selectedPolicyTypes: list<string>,
|
||||||
|
* selectedStates: list<string>,
|
||||||
|
* selectedSeverities: list<string>,
|
||||||
|
* tenantSort: string,
|
||||||
|
* subjectSort: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function draftFilterState(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'selectedPolicyTypes' => $this->normalizeQueryList($this->draftSelectedPolicyTypes),
|
||||||
|
'selectedStates' => $this->normalizeQueryList($this->draftSelectedStates),
|
||||||
|
'selectedSeverities' => $this->normalizeQueryList($this->draftSelectedSeverities),
|
||||||
|
'tenantSort' => $this->normalizeTenantSort($this->draftTenantSort),
|
||||||
|
'subjectSort' => $this->normalizeSubjectSort($this->draftSubjectSort),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* selectedPolicyTypes: list<string>,
|
||||||
|
* selectedStates: list<string>,
|
||||||
|
* selectedSeverities: list<string>,
|
||||||
|
* tenantSort: string,
|
||||||
|
* subjectSort: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function appliedFilterState(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'selectedPolicyTypes' => $this->selectedPolicyTypes,
|
||||||
|
'selectedStates' => $this->selectedStates,
|
||||||
|
'selectedSeverities' => $this->selectedSeverities,
|
||||||
|
'tenantSort' => $this->tenantSort,
|
||||||
|
'subjectSort' => $this->subjectSort,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@ -447,6 +581,27 @@ private function normalizeQueryList(mixed $value): array
|
|||||||
}, $values))));
|
}, $values))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeRequestedMode(mixed $value): string
|
||||||
|
{
|
||||||
|
return in_array((string) $value, ['auto', 'dense', 'compact'], true)
|
||||||
|
? (string) $value
|
||||||
|
: 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTenantSort(mixed $value): string
|
||||||
|
{
|
||||||
|
return in_array((string) $value, ['tenant_name', 'deviation_count', 'freshness_urgency'], true)
|
||||||
|
? (string) $value
|
||||||
|
: 'tenant_name';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeSubjectSort(mixed $value): string
|
||||||
|
{
|
||||||
|
return in_array((string) $value, ['deviation_breadth', 'policy_type', 'display_name'], true)
|
||||||
|
? (string) $value
|
||||||
|
: 'deviation_breadth';
|
||||||
|
}
|
||||||
|
|
||||||
private function compareAssignedTenantsDisabledReason(): ?string
|
private function compareAssignedTenantsDisabledReason(): ?string
|
||||||
{
|
{
|
||||||
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
||||||
@ -520,11 +675,12 @@ private function routeParameters(array $overrides = []): array
|
|||||||
{
|
{
|
||||||
return array_filter([
|
return array_filter([
|
||||||
'record' => $this->getRecord(),
|
'record' => $this->getRecord(),
|
||||||
|
'mode' => $this->requestedMode !== 'auto' ? $this->requestedMode : null,
|
||||||
'policy_type' => $this->selectedPolicyTypes,
|
'policy_type' => $this->selectedPolicyTypes,
|
||||||
'state' => $this->selectedStates,
|
'state' => $this->selectedStates,
|
||||||
'severity' => $this->selectedSeverities,
|
'severity' => $this->selectedSeverities,
|
||||||
'tenant_sort' => $this->tenantSort,
|
'tenant_sort' => $this->tenantSort !== 'tenant_name' ? $this->tenantSort : null,
|
||||||
'subject_sort' => $this->subjectSort,
|
'subject_sort' => $this->subjectSort !== 'deviation_breadth' ? $this->subjectSort : null,
|
||||||
'subject_key' => $this->focusedSubjectKey,
|
'subject_key' => $this->focusedSubjectKey,
|
||||||
...$overrides,
|
...$overrides,
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
], static fn (mixed $value): bool => $value !== null && $value !== [] && $value !== '');
|
||||||
@ -557,4 +713,44 @@ private function workspace(): ?Workspace
|
|||||||
{
|
{
|
||||||
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
|
return Workspace::query()->whereKey((int) $this->getRecord()->workspace_id)->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function presentationState(): array
|
||||||
|
{
|
||||||
|
$resolvedMode = $this->resolvePresentationMode($this->visibleTenantCount());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'requestedMode' => $this->requestedMode,
|
||||||
|
'resolvedMode' => $resolvedMode,
|
||||||
|
'visibleTenantCount' => $this->visibleTenantCount(),
|
||||||
|
'activeFilterCount' => $this->activeFilterCount(),
|
||||||
|
'hasStagedFilterChanges' => $this->hasStagedFilterChanges(),
|
||||||
|
'autoRefreshActive' => (bool) ($this->matrix['hasActiveRuns'] ?? false),
|
||||||
|
'lastUpdatedAt' => $this->matrix['lastUpdatedAt'] ?? null,
|
||||||
|
'canOverrideMode' => $this->visibleTenantCount() > 0,
|
||||||
|
'compactModeAvailable' => $this->canUseCompactMode(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visibleTenantCount(): int
|
||||||
|
{
|
||||||
|
$reference = is_array($this->matrix['reference'] ?? null) ? $this->matrix['reference'] : [];
|
||||||
|
|
||||||
|
return (int) ($reference['visibleTenantCount'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePresentationMode(int $visibleTenantCount): string
|
||||||
|
{
|
||||||
|
if ($this->requestedMode === 'dense') {
|
||||||
|
return 'dense';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->requestedMode === 'compact' && $visibleTenantCount <= 1) {
|
||||||
|
return 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $visibleTenantCount > 1 ? 'dense' : 'compact';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@
|
|||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||||
use Filament\Navigation\NavigationItem;
|
use Filament\Navigation\NavigationItem;
|
||||||
@ -62,6 +63,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->brandLogoHeight('2rem')
|
->brandLogoHeight('2rem')
|
||||||
->homeUrl(fn (): string => route('admin.home'))
|
->homeUrl(fn (): string => route('admin.home'))
|
||||||
->favicon(asset('favicon.ico'))
|
->favicon(asset('favicon.ico'))
|
||||||
|
->font(null, provider: LocalFontProvider::class, preload: [])
|
||||||
->authenticatedRoutes(function (Panel $panel): void {
|
->authenticatedRoutes(function (Panel $panel): void {
|
||||||
ChooseWorkspace::registerRoutes($panel);
|
ChooseWorkspace::registerRoutes($panel);
|
||||||
ChooseTenant::registerRoutes($panel);
|
ChooseTenant::registerRoutes($panel);
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Http\Middleware\UseSystemSessionCookie;
|
use App\Http\Middleware\UseSystemSessionCookie;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\Filament\PanelThemeAsset;
|
use App\Support\Filament\PanelThemeAsset;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -31,6 +32,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->path('system')
|
->path('system')
|
||||||
->authGuard('platform')
|
->authGuard('platform')
|
||||||
->login(Login::class)
|
->login(Login::class)
|
||||||
|
->font(null, provider: LocalFontProvider::class, preload: [])
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Blue,
|
'primary' => Color::Blue,
|
||||||
])
|
])
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
use App\Support\Middleware\DenyNonMemberTenantAccess;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\FontProviders\LocalFontProvider;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\AuthenticateSession;
|
use Filament\Http\Middleware\AuthenticateSession;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
@ -40,6 +41,7 @@ public function panel(Panel $panel): Panel
|
|||||||
->brandLogo(fn () => view('filament.admin.logo'))
|
->brandLogo(fn () => view('filament.admin.logo'))
|
||||||
->brandLogoHeight('2rem')
|
->brandLogoHeight('2rem')
|
||||||
->favicon(asset('favicon.ico'))
|
->favicon(asset('favicon.ico'))
|
||||||
|
->font(null, provider: LocalFontProvider::class, preload: [])
|
||||||
->tenant(Tenant::class, slugAttribute: 'external_id')
|
->tenant(Tenant::class, slugAttribute: 'external_id')
|
||||||
->tenantRoutePrefix(null)
|
->tenantRoutePrefix(null)
|
||||||
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
->tenantMenu(fn (): bool => filled(Filament::getTenant()))
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
use App\Services\Baselines\BaselineSnapshotTruthResolver;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
@ -142,9 +141,19 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
|
|||||||
'diagnostic_only',
|
'diagnostic_only',
|
||||||
'unusable',
|
'unusable',
|
||||||
]),
|
]),
|
||||||
|
'supportSurfaceState' => [
|
||||||
|
'legendMode' => 'grouped',
|
||||||
|
'showActiveFilterSummary' => true,
|
||||||
|
'showLastUpdated' => true,
|
||||||
|
'showAutoRefreshHint' => false,
|
||||||
|
'showBlockingRefreshState' => false,
|
||||||
|
],
|
||||||
|
'lastUpdatedAt' => now()->toIso8601String(),
|
||||||
'tenantSummaries' => [],
|
'tenantSummaries' => [],
|
||||||
'subjectSummaries' => [],
|
'subjectSummaries' => [],
|
||||||
'rows' => [],
|
'rows' => [],
|
||||||
|
'denseRows' => [],
|
||||||
|
'compactResults' => [],
|
||||||
'emptyState' => $this->emptyState(
|
'emptyState' => $this->emptyState(
|
||||||
reference: $reference,
|
reference: $reference,
|
||||||
snapshotItemsCount: $snapshotItems->count(),
|
snapshotItemsCount: $snapshotItems->count(),
|
||||||
@ -247,6 +256,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
|
|||||||
$rows,
|
$rows,
|
||||||
);
|
);
|
||||||
$bundle['rows'] = $rows;
|
$bundle['rows'] = $rows;
|
||||||
|
$bundle['denseRows'] = $rows;
|
||||||
|
$bundle['compactResults'] = $this->compactResults($rows, $tenantSummaries);
|
||||||
$bundle['emptyState'] = $this->emptyState(
|
$bundle['emptyState'] = $this->emptyState(
|
||||||
reference: $reference,
|
reference: $reference,
|
||||||
snapshotItemsCount: $snapshotItems->count(),
|
snapshotItemsCount: $snapshotItems->count(),
|
||||||
@ -258,6 +269,7 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
|
|||||||
OperationRunStatus::Queued->value,
|
OperationRunStatus::Queued->value,
|
||||||
OperationRunStatus::Running->value,
|
OperationRunStatus::Running->value,
|
||||||
], true));
|
], true));
|
||||||
|
$bundle['supportSurfaceState']['showAutoRefreshHint'] = $bundle['hasActiveRuns'];
|
||||||
|
|
||||||
return $bundle;
|
return $bundle;
|
||||||
}
|
}
|
||||||
@ -476,6 +488,9 @@ private function cellFor(
|
|||||||
'state' => $state,
|
'state' => $state,
|
||||||
'severity' => $finding instanceof Finding ? (string) $finding->severity : null,
|
'severity' => $finding instanceof Finding ? (string) $finding->severity : null,
|
||||||
'trustLevel' => $trustLevel,
|
'trustLevel' => $trustLevel,
|
||||||
|
'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot),
|
||||||
|
'attentionLevel' => $this->attentionLevel($state, $finding instanceof Finding ? (string) $finding->severity : null),
|
||||||
|
'reasonSummary' => $this->reasonSummary($state, $reasonCode, $policyTypeCovered),
|
||||||
'reasonCode' => $reasonCode,
|
'reasonCode' => $reasonCode,
|
||||||
'compareRunId' => $completedRun?->getKey(),
|
'compareRunId' => $completedRun?->getKey(),
|
||||||
'findingId' => $finding?->getKey(),
|
'findingId' => $finding?->getKey(),
|
||||||
@ -526,6 +541,42 @@ private function runReasonCode(?OperationRun $run): ?string
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function attentionLevel(string $state, ?string $severity): string
|
||||||
|
{
|
||||||
|
if (in_array($state, ['differ', 'missing', 'ambiguous'], true) || in_array($severity, [
|
||||||
|
Finding::SEVERITY_HIGH,
|
||||||
|
Finding::SEVERITY_CRITICAL,
|
||||||
|
], true)) {
|
||||||
|
return 'needs_attention';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($state, ['stale_result', 'not_compared'], true)) {
|
||||||
|
return 'refresh_recommended';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === 'match') {
|
||||||
|
return 'aligned';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reasonSummary(string $state, ?string $reasonCode, bool $policyTypeCovered): ?string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
'differ' => 'A baseline compare finding exists for this subject.',
|
||||||
|
'missing' => 'The reference subject is missing from the tenant result.',
|
||||||
|
'ambiguous' => $reasonCode !== null
|
||||||
|
? Str::headline(str_replace(['.', '_'], ' ', $reasonCode))
|
||||||
|
: 'Identity or evidence stayed ambiguous.',
|
||||||
|
'stale_result' => 'Refresh recommended before acting on this result.',
|
||||||
|
'not_compared' => $policyTypeCovered
|
||||||
|
? 'No completed compare result is available yet.'
|
||||||
|
: 'Policy type coverage was not proven in the latest compare run.',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool
|
private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool
|
||||||
{
|
{
|
||||||
if (! $run instanceof OperationRun || ! $run->finished_at) {
|
if (! $run instanceof OperationRun || ! $run->finished_at) {
|
||||||
@ -599,6 +650,7 @@ private function subjectSummary(array $subject, array $cells): array
|
|||||||
'notComparedBreadth' => $this->countStates($cells, ['not_compared']),
|
'notComparedBreadth' => $this->countStates($cells, ['not_compared']),
|
||||||
'maxSeverity' => $this->maxSeverity($cells),
|
'maxSeverity' => $this->maxSeverity($cells),
|
||||||
'trustLevel' => $this->worstTrustLevel($cells),
|
'trustLevel' => $this->worstTrustLevel($cells),
|
||||||
|
'attentionLevel' => $this->worstAttentionLevel($cells),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -724,6 +776,28 @@ private function trustRank(string $trustLevel): int
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $cells
|
||||||
|
*/
|
||||||
|
private function worstAttentionLevel(array $cells): string
|
||||||
|
{
|
||||||
|
return collect($cells)
|
||||||
|
->map(static fn (array $cell): string => (string) ($cell['attentionLevel'] ?? 'review'))
|
||||||
|
->sortByDesc(fn (string $level): int => $this->attentionRank($level))
|
||||||
|
->first() ?? 'review';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attentionRank(string $attentionLevel): int
|
||||||
|
{
|
||||||
|
return match ($attentionLevel) {
|
||||||
|
'needs_attention' => 4,
|
||||||
|
'refresh_recommended' => 3,
|
||||||
|
'review' => 2,
|
||||||
|
'aligned' => 1,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array<string, mixed>> $rows
|
* @param list<array<string, mixed>> $rows
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
@ -812,6 +886,53 @@ private function sortCellsForTenants(array $cells, array $tenantSummaries): arra
|
|||||||
return array_values($cells);
|
return array_values($cells);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $rows
|
||||||
|
* @param list<array<string, mixed>> $tenantSummaries
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function compactResults(array $rows, array $tenantSummaries): array
|
||||||
|
{
|
||||||
|
if (count($tenantSummaries) !== 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantSummary = $tenantSummaries[0];
|
||||||
|
$tenantId = (int) ($tenantSummary['tenantId'] ?? 0);
|
||||||
|
|
||||||
|
if ($tenantId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(function (array $row) use ($tenantId, $tenantSummary): array {
|
||||||
|
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
|
||||||
|
$cell = collect($row['cells'] ?? [])->firstWhere('tenantId', $tenantId) ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenantId' => $tenantId,
|
||||||
|
'tenantName' => (string) ($tenantSummary['tenantName'] ?? 'Tenant'),
|
||||||
|
'subjectKey' => (string) ($subject['subjectKey'] ?? ''),
|
||||||
|
'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject',
|
||||||
|
'policyType' => (string) ($subject['policyType'] ?? ''),
|
||||||
|
'baselineExternalId' => $subject['baselineExternalId'] ?? null,
|
||||||
|
'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0),
|
||||||
|
'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0),
|
||||||
|
'ambiguousBreadth' => (int) ($subject['ambiguousBreadth'] ?? 0),
|
||||||
|
'state' => (string) ($cell['state'] ?? 'not_compared'),
|
||||||
|
'freshnessState' => (string) ($cell['freshnessState'] ?? 'unknown'),
|
||||||
|
'trustLevel' => (string) ($cell['trustLevel'] ?? 'unusable'),
|
||||||
|
'attentionLevel' => (string) ($cell['attentionLevel'] ?? 'review'),
|
||||||
|
'severity' => $cell['severity'] ?? null,
|
||||||
|
'reasonSummary' => $cell['reasonSummary'] ?? null,
|
||||||
|
'reasonCode' => $cell['reasonCode'] ?? null,
|
||||||
|
'compareRunId' => $cell['compareRunId'] ?? null,
|
||||||
|
'findingId' => $cell['findingId'] ?? null,
|
||||||
|
'lastComparedAt' => $cell['lastComparedAt'] ?? null,
|
||||||
|
'policyTypeCovered' => $cell['policyTypeCovered'] ?? true,
|
||||||
|
];
|
||||||
|
}, $rows));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $reference
|
* @param array<string, mixed> $reference
|
||||||
* @return array{title: string, body: string}|null
|
* @return array{title: string, body: string}|null
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
@ -11,7 +13,7 @@
|
|||||||
|
|
||||||
pest()->browser()->timeout(15_000);
|
pest()->browser()->timeout(15_000);
|
||||||
|
|
||||||
it('smokes the baseline compare matrix render, filter interaction, and finding drilldown continuity', function (): void {
|
it('smokes dense multi-tenant scanning and finding drilldown continuity', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
@ -26,7 +28,7 @@
|
|||||||
$fixture['snapshot'],
|
$fixture['snapshot'],
|
||||||
);
|
);
|
||||||
|
|
||||||
$finding = $this->makeBaselineCompareMatrixFinding(
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
$fixture['visibleTenant'],
|
$fixture['visibleTenant'],
|
||||||
$fixture['profile'],
|
$fixture['profile'],
|
||||||
$run,
|
$run,
|
||||||
@ -46,40 +48,102 @@
|
|||||||
|
|
||||||
$page
|
$page
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->waitForText('Visible-set baseline')
|
->waitForText('Requested: Auto mode. Resolved: Dense mode.')
|
||||||
->assertSee('Reference overview')
|
->assertSee('Dense multi-tenant scan')
|
||||||
->assertSee('No narrowing filters are active')
|
->assertSee('Grouped legend')
|
||||||
->assertSee('Subject-by-tenant matrix')
|
->assertSee('Open finding')
|
||||||
->assertSee('WiFi Corp Profile')
|
->assertSee('More follow-up')
|
||||||
->assertSee('Windows Compliance')
|
|
||||||
->assertSee('Open finding');
|
|
||||||
|
|
||||||
$page->script(<<<'JS'
|
|
||||||
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')
|
->click('Open finding')
|
||||||
->waitForText('Back to compare matrix')
|
->waitForText('Back to compare matrix')
|
||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertSee('Back to compare matrix');
|
->assertSee('Back to compare matrix');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('smokes the compact single-tenant path when only one visible tenant remains', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => Finding::SEVERITY_HIGH],
|
||||||
|
);
|
||||||
|
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewer->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($viewer)->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
||||||
|
|
||||||
|
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->waitForText('Requested: Auto mode. Resolved: Compact mode.')
|
||||||
|
->assertSee('Compact compare results')
|
||||||
|
->assertSee('Open finding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('smokes filtered zero-results reset flow and passive refresh cues without losing the matrix route', 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(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($fixture['user'])->withSession([
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $fixture['workspace']->getKey(),
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $fixture['workspace']->getKey() => (int) $fixture['visibleTenant']->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $fixture['workspace']->getKey());
|
||||||
|
|
||||||
|
visit(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
|
||||||
|
->assertNoJavaScriptErrors()
|
||||||
|
->waitForText('No rows match the current filters')
|
||||||
|
->assertSee('Passive auto-refresh every 5 seconds')
|
||||||
|
->click('Reset filters')
|
||||||
|
->waitForText('Dense multi-tenant scan')
|
||||||
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
->assertNoJavaScriptErrors();
|
||||||
|
});
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
@ -11,7 +13,7 @@
|
|||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
it('builds visible-set-only tenant and subject summaries from assigned baseline truth', function (): void {
|
it('builds visible-set-only dense rows plus support metadata from assigned baseline truth', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
||||||
@ -49,7 +51,7 @@
|
|||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
||||||
|
|
||||||
$wifiRow = collect($matrix['rows'])->first(
|
$wifiRow = collect($matrix['denseRows'])->first(
|
||||||
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -61,10 +63,16 @@
|
|||||||
])
|
])
|
||||||
->and($wifiRow)->not->toBeNull()
|
->and($wifiRow)->not->toBeNull()
|
||||||
->and($wifiRow['subject']['deviationBreadth'])->toBe(1)
|
->and($wifiRow['subject']['deviationBreadth'])->toBe(1)
|
||||||
->and(count($wifiRow['cells']))->toBe(2);
|
->and($wifiRow['subject']['attentionLevel'])->toBe('needs_attention')
|
||||||
|
->and(count($wifiRow['cells']))->toBe(2)
|
||||||
|
->and($matrix['denseRows'])->toHaveCount(2)
|
||||||
|
->and($matrix['compactResults'])->toBeEmpty()
|
||||||
|
->and($matrix['supportSurfaceState']['legendMode'])->toBe('grouped')
|
||||||
|
->and($matrix['supportSurfaceState']['showAutoRefreshHint'])->toBeFalse()
|
||||||
|
->and($matrix['lastUpdatedAt'])->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('derives matrix cell precedence from compare freshness, evidence gaps, findings, and uncovered policy types', function (): void {
|
it('derives matrix cell precedence, freshness, attention, and reason summaries from compare truth', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$matchTenant = $fixture['visibleTenant'];
|
$matchTenant = $fixture['visibleTenant'];
|
||||||
@ -188,23 +196,33 @@
|
|||||||
|
|
||||||
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $fixture['user']);
|
||||||
|
|
||||||
$wifiRow = collect($matrix['rows'])->first(
|
$wifiRow = collect($matrix['denseRows'])->first(
|
||||||
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
static fn (array $row): bool => ($row['subject']['subjectKey'] ?? null) === 'wifi-corp-profile',
|
||||||
);
|
);
|
||||||
|
|
||||||
$statesByTenant = collect($wifiRow['cells'] ?? [])
|
$cellsByTenant = collect($wifiRow['cells'] ?? [])
|
||||||
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => (string) $cell['state']])
|
->mapWithKeys(static fn (array $cell): array => [(int) $cell['tenantId'] => $cell])
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
expect($statesByTenant[(int) $matchTenant->getKey()] ?? null)->toBe('match')
|
expect($cellsByTenant[(int) $matchTenant->getKey()]['state'] ?? null)->toBe('match')
|
||||||
->and($statesByTenant[(int) $differTenant->getKey()] ?? null)->toBe('differ')
|
->and($cellsByTenant[(int) $matchTenant->getKey()]['attentionLevel'] ?? null)->toBe('aligned')
|
||||||
->and($statesByTenant[(int) $missingTenant->getKey()] ?? null)->toBe('missing')
|
->and($cellsByTenant[(int) $differTenant->getKey()]['state'] ?? null)->toBe('differ')
|
||||||
->and($statesByTenant[(int) $ambiguousTenant->getKey()] ?? null)->toBe('ambiguous')
|
->and($cellsByTenant[(int) $differTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
||||||
->and($statesByTenant[(int) $notComparedTenant->getKey()] ?? null)->toBe('not_compared')
|
->and($cellsByTenant[(int) $differTenant->getKey()]['reasonSummary'] ?? null)->toBe('A baseline compare finding exists for this subject.')
|
||||||
->and($statesByTenant[(int) $staleTenant->getKey()] ?? null)->toBe('stale_result');
|
->and($cellsByTenant[(int) $missingTenant->getKey()]['state'] ?? null)->toBe('missing')
|
||||||
|
->and($cellsByTenant[(int) $missingTenant->getKey()]['attentionLevel'] ?? null)->toBe('needs_attention')
|
||||||
|
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['state'] ?? null)->toBe('ambiguous')
|
||||||
|
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
|
||||||
|
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
|
||||||
|
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
|
||||||
|
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')
|
||||||
|
->and($cellsByTenant[(int) $staleTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies policy-type, state, severity, and subject-focus filters honestly', function (): void {
|
it('applies policy, state, severity, and subject-focus filters honestly without changing compare truth', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
$visibleRun = $this->makeBaselineCompareMatrixRun(
|
||||||
@ -245,10 +263,55 @@
|
|||||||
'focusedSubjectKey' => 'wifi-corp-profile',
|
'focusedSubjectKey' => 'wifi-corp-profile',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(count($deviceOnly['rows']))->toBe(1)
|
expect(count($deviceOnly['denseRows']))->toBe(1)
|
||||||
->and($deviceOnly['rows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
|
->and($deviceOnly['denseRows'][0]['subject']['policyType'])->toBe('deviceConfiguration')
|
||||||
->and(count($driftOnly['rows']))->toBe(1)
|
->and(count($driftOnly['denseRows']))->toBe(1)
|
||||||
->and($driftOnly['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
|
->and($driftOnly['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile')
|
||||||
->and(count($subjectFocus['rows']))->toBe(1)
|
->and(count($subjectFocus['denseRows']))->toBe(1)
|
||||||
->and($subjectFocus['rows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
|
->and($subjectFocus['denseRows'][0]['subject']['subjectKey'])->toBe('wifi-corp-profile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits compact single-tenant results from the visible set only when one tenant remains in scope', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => 'critical'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewer->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$matrix = app(BaselineCompareMatrixBuilder::class)->build($fixture['profile'], $viewer);
|
||||||
|
|
||||||
|
expect($matrix['reference']['assignedTenantCount'])->toBe(3)
|
||||||
|
->and($matrix['reference']['visibleTenantCount'])->toBe(1)
|
||||||
|
->and($matrix['compactResults'])->toHaveCount(2)
|
||||||
|
->and(collect($matrix['compactResults'])->pluck('tenantId')->unique()->all())->toBe([(int) $fixture['visibleTenant']->getKey()])
|
||||||
|
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['state'] ?? null)->toBe('differ')
|
||||||
|
->and(collect($matrix['compactResults'])->firstWhere('subjectKey', 'wifi-corp-profile')['attentionLevel'] ?? null)->toBe('needs_attention');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,13 +4,15 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
|
|
||||||
it('renders the baseline compare matrix with reference truth, legends, and explicit drilldowns', function (): void {
|
it('renders dense auto mode with sticky subject behavior and compact support surfaces', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
@ -38,25 +40,20 @@
|
|||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Visible-set baseline')
|
->assertSee('Visible-set baseline')
|
||||||
->assertSee('Reference overview')
|
->assertSee('Requested: Auto mode. Resolved: Dense mode.')
|
||||||
->assertSee('fi-fo-checkbox-list', false)
|
->assertDontSee('fonts/filament/filament/inter/inter-latin-wght-normal', false)
|
||||||
->assertSee('fi-fo-select', false)
|
->assertDontSee('Passive auto-refresh every 5 seconds')
|
||||||
->assertSee('State legend')
|
->assertSee('Grouped legend')
|
||||||
->assertSee('Tenant summaries')
|
->assertSee('Apply filters')
|
||||||
->assertSee('Subject-by-tenant matrix')
|
->assertSee('Compact unlocks at one visible tenant')
|
||||||
->assertSee('No narrowing filters are active. Showing every visible subject and tenant in the current baseline scope.')
|
->assertSee('Dense multi-tenant scan')
|
||||||
->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 finding')
|
||||||
->assertSee('Open tenant compare')
|
->assertSee('More follow-up')
|
||||||
->assertSee('data-testid="matrix-active-filters"', false)
|
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false)
|
||||||
->assertSee('sticky left-0', false);
|
->assertSee('sticky left-0', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps query-backed filters and subject focus on the matrix route and drilldown links', function (): void {
|
it('stages heavy filter changes until apply and preserves mode and subject continuity in drilldown urls', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$run = $this->makeBaselineCompareMatrixRun(
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
@ -70,7 +67,6 @@
|
|||||||
$fixture['profile'],
|
$fixture['profile'],
|
||||||
$run,
|
$run,
|
||||||
'wifi-corp-profile',
|
'wifi-corp-profile',
|
||||||
['severity' => 'critical'],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
$this->makeBaselineCompareMatrixRun(
|
||||||
@ -82,26 +78,109 @@
|
|||||||
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace'], $fixture['visibleTenant']);
|
||||||
|
|
||||||
$component = Livewire::withQueryParams([
|
$component = Livewire::withQueryParams([
|
||||||
|
'mode' => 'dense',
|
||||||
'policy_type' => ['deviceConfiguration'],
|
'policy_type' => ['deviceConfiguration'],
|
||||||
'state' => ['differ'],
|
'state' => ['differ'],
|
||||||
'severity' => ['critical'],
|
'severity' => ['high'],
|
||||||
'subject_key' => 'wifi-corp-profile',
|
'subject_key' => 'wifi-corp-profile',
|
||||||
])
|
])
|
||||||
->actingAs($fixture['user'])
|
->actingAs($fixture['user'])
|
||||||
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||||
->assertSee('4 active filters')
|
->assertSet('requestedMode', 'dense')
|
||||||
->assertSee('Policy types: 1')
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
->assertSee('Focused subject: wifi-corp-profile')
|
->assertSee('Focused subject')
|
||||||
->assertSee('Clear subject focus')
|
->assertSee('wifi-corp-profile');
|
||||||
->assertDontSee('Windows Compliance');
|
|
||||||
|
|
||||||
$tenantCompareUrl = $component->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
|
expect($component->instance()->hasStagedFilterChanges())->toBeFalse();
|
||||||
$findingUrl = $component->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
|
|
||||||
|
|
||||||
expect($tenantCompareUrl)->toContain('baseline_profile_id='.(int) $fixture['profile']->getKey())
|
$component
|
||||||
->and($tenantCompareUrl)->toContain('subject_key=wifi-corp-profile')
|
->set('draftSelectedPolicyTypes', ['compliancePolicy'])
|
||||||
->and($tenantCompareUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix')
|
->set('draftSelectedStates', ['match'])
|
||||||
|
->set('draftSelectedSeverities', [])
|
||||||
|
->set('draftTenantSort', 'freshness_urgency')
|
||||||
|
->set('draftSubjectSort', 'display_name')
|
||||||
|
->assertSee('Draft filters are staged');
|
||||||
|
|
||||||
|
expect($component->instance()->hasStagedFilterChanges())->toBeTrue();
|
||||||
|
|
||||||
|
$component->call('applyFilters')->assertRedirect(
|
||||||
|
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&policy_type%5B0%5D=compliancePolicy&state%5B0%5D=match&tenant_sort=freshness_urgency&subject_sort=display_name&subject_key=wifi-corp-profile'
|
||||||
|
);
|
||||||
|
|
||||||
|
$applied = Livewire::withQueryParams([
|
||||||
|
'mode' => 'dense',
|
||||||
|
'policy_type' => ['compliancePolicy'],
|
||||||
|
'state' => ['match'],
|
||||||
|
'tenant_sort' => 'freshness_urgency',
|
||||||
|
'subject_sort' => 'display_name',
|
||||||
|
'subject_key' => 'wifi-corp-profile',
|
||||||
|
])
|
||||||
|
->actingAs($fixture['user'])
|
||||||
|
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()]);
|
||||||
|
|
||||||
|
$tenantCompareUrl = $applied->instance()->tenantCompareUrl((int) $fixture['visibleTenant']->getKey(), 'wifi-corp-profile');
|
||||||
|
$findingUrl = $applied->instance()->findingUrl((int) $fixture['visibleTenant']->getKey(), (int) $finding->getKey(), 'wifi-corp-profile');
|
||||||
|
|
||||||
|
expect(urldecode((string) $tenantCompareUrl))->toContain('mode=dense')
|
||||||
|
->and(urldecode((string) $tenantCompareUrl))->toContain('subject_key=wifi-corp-profile')
|
||||||
|
->and(urldecode((string) $findingUrl))->toContain('mode=dense')
|
||||||
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
|
->and($findingUrl)->toContain('nav%5Bsource_surface%5D=baseline_compare_matrix');
|
||||||
|
|
||||||
|
$applied->call('resetFilters')->assertRedirect(
|
||||||
|
BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves auto to compact for the visible-set-only single-tenant edge case and still allows dense override', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
$run = $this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixRun(
|
||||||
|
$fixture['visibleTenantTwo'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$fixture['snapshot'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->makeBaselineCompareMatrixFinding(
|
||||||
|
$fixture['visibleTenant'],
|
||||||
|
$fixture['profile'],
|
||||||
|
$run,
|
||||||
|
'wifi-corp-profile',
|
||||||
|
['severity' => 'critical'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
|
'user_id' => (int) $viewer->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewer->tenants()->syncWithoutDetaching([
|
||||||
|
(int) $fixture['visibleTenant']->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = $this->setAdminWorkspaceContext($viewer, $fixture['workspace'], $fixture['visibleTenant']);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Requested: Auto mode. Resolved: Compact mode.')
|
||||||
|
->assertSee('Compact compare results')
|
||||||
|
->assertSee('data-testid="baseline-compare-matrix-compact-shell"', false)
|
||||||
|
->assertDontSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
||||||
|
|
||||||
|
$this->withSession($session)
|
||||||
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
->assertSee('data-testid="baseline-compare-matrix-dense-shell"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void {
|
it('renders a blocked state when the baseline profile has no usable reference snapshot', function (): void {
|
||||||
@ -133,9 +212,9 @@
|
|||||||
|
|
||||||
it('renders an empty state when the assigned set is not visible to the current actor', function (): void {
|
it('renders an empty state when the assigned set is not visible to the current actor', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
$viewer = \App\Models\User::factory()->create();
|
$viewer = User::factory()->create();
|
||||||
|
|
||||||
\App\Models\WorkspaceMembership::factory()->create([
|
WorkspaceMembership::factory()->create([
|
||||||
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
'workspace_id' => (int) $fixture['workspace']->getKey(),
|
||||||
'user_id' => (int) $viewer->getKey(),
|
'user_id' => (int) $viewer->getKey(),
|
||||||
'role' => 'owner',
|
'role' => 'owner',
|
||||||
@ -149,7 +228,7 @@
|
|||||||
->assertSee('No visible assigned tenants');
|
->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 {
|
it('renders a passive auto-refresh cue instead of a perpetual blocking state while compare runs remain active', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
$this->makeBaselineCompareMatrixRun(
|
||||||
@ -169,12 +248,12 @@
|
|||||||
$this->withSession($session)
|
$this->withSession($session)
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Auto-refresh every 5 seconds while compare runs are queued or running.')
|
->assertSee('Passive auto-refresh every 5 seconds')
|
||||||
->assertSee('wire:poll.5s="pollMatrix"', false)
|
->assertSee('wire:poll.5s="pollMatrix"', false)
|
||||||
->assertDontSee('Refreshing matrix');
|
->assertSee('Refresh matrix');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders an empty state when no rows match the current filters', function (): void {
|
it('renders a filtered zero-result state that preserves mode and offers reset filters as the primary cta', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
$this->makeBaselineCompareMatrixRun(
|
$this->makeBaselineCompareMatrixRun(
|
||||||
@ -192,7 +271,9 @@
|
|||||||
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
$session = $this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
$this->withSession($session)
|
$this->withSession($session)
|
||||||
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?state[]=missing')
|
->get(BaselineProfileResource::compareMatrixUrl($fixture['profile']).'?mode=dense&state[]=missing')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('No rows match the current filters');
|
->assertSee('Requested: Dense mode. Resolved: Dense mode.')
|
||||||
|
->assertSee('No rows match the current filters')
|
||||||
|
->assertSee('Reset filters');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Baseline Compare Matrix Operator Mode Internal Surface Contract
|
title: Baseline Compare Matrix Operator Mode Internal Surface Contract
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
summary: Internal logical contract for adaptive operator-density rendering on the existing baseline compare matrix route
|
summary: Internal logical contract for adaptive operator-density rendering on the existing baseline compare matrix route
|
||||||
description: |
|
description: |
|
||||||
This contract is an internal planning artifact for Spec 191. The affected surface
|
This contract is an internal planning artifact for Spec 191. The affected surface
|
||||||
@ -18,9 +18,12 @@ x-baseline-compare-operator-mode-consumers:
|
|||||||
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
|
- apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php
|
||||||
mustRender:
|
mustRender:
|
||||||
- reference
|
- reference
|
||||||
|
- requested_vs_resolved_mode
|
||||||
- presentation_state
|
- presentation_state
|
||||||
- support_surface_state
|
- support_surface_state
|
||||||
- applied_filters
|
- applied_filters
|
||||||
|
- draft_filters
|
||||||
|
- staged_filter_changes
|
||||||
- tenant_summaries
|
- tenant_summaries
|
||||||
- dense_rows_or_compact_results
|
- dense_rows_or_compact_results
|
||||||
- last_updated_at
|
- last_updated_at
|
||||||
@ -37,6 +40,8 @@ x-baseline-compare-operator-mode-consumers:
|
|||||||
- selectedPolicyTypes
|
- selectedPolicyTypes
|
||||||
- selectedStates
|
- selectedStates
|
||||||
- selectedSeverities
|
- selectedSeverities
|
||||||
|
- tenantSort
|
||||||
|
- subjectSort
|
||||||
paths:
|
paths:
|
||||||
/admin/baseline-profiles/{profile}/compare-matrix:
|
/admin/baseline-profiles/{profile}/compare-matrix:
|
||||||
get:
|
get:
|
||||||
@ -270,7 +275,9 @@ components:
|
|||||||
- activeFilterCount
|
- activeFilterCount
|
||||||
- hasStagedFilterChanges
|
- hasStagedFilterChanges
|
||||||
- autoRefreshActive
|
- autoRefreshActive
|
||||||
|
- lastUpdatedAt
|
||||||
- canOverrideMode
|
- canOverrideMode
|
||||||
|
- compactModeAvailable
|
||||||
properties:
|
properties:
|
||||||
requestedMode:
|
requestedMode:
|
||||||
$ref: '#/components/schemas/PresentationMode'
|
$ref: '#/components/schemas/PresentationMode'
|
||||||
@ -279,6 +286,10 @@ components:
|
|||||||
enum:
|
enum:
|
||||||
- dense
|
- dense
|
||||||
- compact
|
- compact
|
||||||
|
description: |
|
||||||
|
Final render mode after evaluating the requested route mode against the
|
||||||
|
visible tenant count. A requested `compact` mode may still resolve to
|
||||||
|
`dense` when more than one visible tenant remains in scope.
|
||||||
visibleTenantCount:
|
visibleTenantCount:
|
||||||
type: integer
|
type: integer
|
||||||
activeFilterCount:
|
activeFilterCount:
|
||||||
@ -294,6 +305,8 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
canOverrideMode:
|
canOverrideMode:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
compactModeAvailable:
|
||||||
|
type: boolean
|
||||||
MatrixTenantSummary:
|
MatrixTenantSummary:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@ -477,6 +490,7 @@ components:
|
|||||||
- presentation
|
- presentation
|
||||||
- supportSurface
|
- supportSurface
|
||||||
- appliedFilters
|
- appliedFilters
|
||||||
|
- draftFilters
|
||||||
- tenantSummaries
|
- tenantSummaries
|
||||||
properties:
|
properties:
|
||||||
reference:
|
reference:
|
||||||
@ -487,6 +501,8 @@ components:
|
|||||||
$ref: '#/components/schemas/MatrixSupportSurfaceState'
|
$ref: '#/components/schemas/MatrixSupportSurfaceState'
|
||||||
appliedFilters:
|
appliedFilters:
|
||||||
$ref: '#/components/schemas/MatrixFilterDraft'
|
$ref: '#/components/schemas/MatrixFilterDraft'
|
||||||
|
draftFilters:
|
||||||
|
$ref: '#/components/schemas/MatrixFilterDraft'
|
||||||
tenantSummaries:
|
tenantSummaries:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@ -498,4 +514,5 @@ components:
|
|||||||
compactResults:
|
compactResults:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/CompactSubjectResultView'
|
$ref: '#/components/schemas/CompactSubjectResultView'
|
||||||
|
$ref: '#/components/schemas/CompactSubjectResultView'
|
||||||
|
|||||||
@ -2,7 +2,7 @@ # Feature Specification: Baseline Compare Matrix: High-Density Operator Mode
|
|||||||
|
|
||||||
**Feature Branch**: `191-baseline-compare-operator-mode`
|
**Feature Branch**: `191-baseline-compare-operator-mode`
|
||||||
**Created**: 2026-04-11
|
**Created**: 2026-04-11
|
||||||
**Status**: Draft
|
**Status**: Approved
|
||||||
**Input**: User description: "Spec Candidate 190b — Baseline Compare Matrix: High-Density Operator Mode"
|
**Input**: User description: "Spec Candidate 190b — Baseline Compare Matrix: High-Density Operator Mode"
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
@ -51,6 +51,12 @@ ## Operator Surface Contract *(mandatory when operator-facing surfaces are chang
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| Workspace baseline compare matrix | Workspace operator | Matrix / triage surface | Where is the meaningful drift across the visible tenant set, how trustworthy is it, and where should I go next? | Subject-by-tenant state, trust, freshness, severity or attention signal, visible-set filter scope, mode, last updated | Raw reason codes, run identifiers, detailed evidence gaps, low-level compare metadata | compare state, freshness, trust, severity/attention | `simulation only` for compare start; otherwise read-only | Compare assigned tenants, apply or reset filters, switch presentation mode, focus subject, drill into compare/finding/run | none |
|
| Workspace baseline compare matrix | Workspace operator | Matrix / triage surface | Where is the meaningful drift across the visible tenant set, how trustworthy is it, and where should I go next? | Subject-by-tenant state, trust, freshness, severity or attention signal, visible-set filter scope, mode, last updated | Raw reason codes, run identifiers, detailed evidence gaps, low-level compare metadata | compare state, freshness, trust, severity/attention | `simulation only` for compare start; otherwise read-only | Compare assigned tenants, apply or reset filters, switch presentation mode, focus subject, drill into compare/finding/run | none |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Workspace baseline compare matrix | `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` | `Compare assigned tenants` remains the sole primary header action; presentation mode, refresh status, and filter state stay in contextual support surfaces rather than the header | Explicit subject, cell, and tenant drilldown controls only; row click remains forbidden | none; follow-up links remain inside compact cell or compact-result affordances only | none | `Reset filters` becomes the single primary CTA when filters reduce the visible row set to zero; otherwise the surface keeps the existing compare-start guidance and no duplicate empty-state CTA | No separate detail header exists; the matrix route remains the canonical working surface | n/a | Existing compare-start run and audit semantics remain unchanged; no new audit event is introduced by presentation changes | Dense-grid and compact-single-tenant rendering are approved custom surface exceptions, but HDR-001 still applies: no pure-navigation header actions and only one primary visible header action |
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
- **New source of truth?**: no
|
- **New source of truth?**: no
|
||||||
@ -94,7 +100,8 @@ ### User Story 2 - Work a single visible tenant in compact mode (Priority: P2)
|
|||||||
**Acceptance Scenarios**:
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
1. **Given** exactly one visible assigned tenant after RBAC scoping, **When** the operator opens the matrix in auto mode, **Then** the page renders compact single-tenant mode instead of dense mode.
|
1. **Given** exactly one visible assigned tenant after RBAC scoping, **When** the operator opens the matrix in auto mode, **Then** the page renders compact single-tenant mode instead of dense mode.
|
||||||
2. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface.
|
2. **Given** more than one tenant is assigned to the baseline profile but RBAC scoping leaves only one tenant visible to the current actor, **When** the operator opens the matrix in auto mode, **Then** the page still resolves to compact mode and all counts and drilldowns remain visible-set-only.
|
||||||
|
3. **Given** compact mode is active, **When** the operator scans a subject entry, **Then** repeated labels, repeated badges, and repeated action chrome are reduced compared with the current matrix surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -111,6 +118,7 @@ ### User Story 3 - Use filters, legends, and status surfaces without losing the
|
|||||||
1. **Given** the operator changes multi-select filters, **When** those changes are staged, **Then** the page uses an explicit apply or reset pattern for heavy filter changes instead of re-rendering noisily on every click.
|
1. **Given** the operator changes multi-select filters, **When** those changes are staged, **Then** the page uses an explicit apply or reset pattern for heavy filter changes instead of re-rendering noisily on every click.
|
||||||
2. **Given** active compare runs or polling are present, **When** the matrix refreshes in the background, **Then** the operator sees a non-blocking update signal and a page-level freshness hint rather than a permanent loading impression.
|
2. **Given** active compare runs or polling are present, **When** the matrix refreshes in the background, **Then** the operator sees a non-blocking update signal and a page-level freshness hint rather than a permanent loading impression.
|
||||||
3. **Given** the operator already understands the legends, **When** the page loads in daily-use mode, **Then** legends are grouped and visually compact, with deeper explanation still available on demand.
|
3. **Given** the operator already understands the legends, **When** the page loads in daily-use mode, **Then** legends are grouped and visually compact, with deeper explanation still available on demand.
|
||||||
|
4. **Given** staged or applied filters reduce the visible subject set to zero, **When** the page renders the filtered result, **Then** it preserves the active presentation mode, shows a clear zero-results empty state, and offers `Reset filters` as the single primary CTA.
|
||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
@ -140,7 +148,7 @@ ## Requirements *(mandatory)*
|
|||||||
|
|
||||||
### Functional Requirements
|
### Functional Requirements
|
||||||
|
|
||||||
- **FR-191-001 Primary working surface**: The matrix body MUST become visually dominant over reference context, filters, legends, and refresh hints.
|
- **FR-191-001 Primary working surface**: On desktop operator viewports (`>= 1280px`), the initial render MUST show the first dense matrix row or first compact result without scrolling past expanded legends or a long filter stack. Reference context, filter summary, and legend summary MAY remain above the working surface, but detailed legend or helper text MUST stay collapsed or secondary by default.
|
||||||
- **FR-191-002 Auto presentation mode**: The page MUST support an `auto` presentation mode that chooses dense multi-tenant mode when more than one visible tenant is in scope and compact single-tenant mode when exactly one visible tenant is in scope.
|
- **FR-191-002 Auto presentation mode**: The page MUST support an `auto` presentation mode that chooses dense multi-tenant mode when more than one visible tenant is in scope and compact single-tenant mode when exactly one visible tenant is in scope.
|
||||||
- **FR-191-003 Manual override**: The page MUST allow a local manual override between `auto`, `dense`, and `compact` presentation without persisting that choice as domain truth or a stored user preference.
|
- **FR-191-003 Manual override**: The page MUST allow a local manual override between `auto`, `dense`, and `compact` presentation without persisting that choice as domain truth or a stored user preference.
|
||||||
- **FR-191-004 Dense multi-tenant layout**: Dense mode MUST render one subject row and one visible-tenant column with a sticky first subject column.
|
- **FR-191-004 Dense multi-tenant layout**: Dense mode MUST render one subject row and one visible-tenant column with a sticky first subject column.
|
||||||
@ -149,8 +157,8 @@ ### Functional Requirements
|
|||||||
- **FR-191-007 Action calming**: Repeated follow-up actions such as tenant compare, finding, or run links MUST become visually secondary. The default focus in dense or compact mode MUST remain the compare state, not the link chrome.
|
- **FR-191-007 Action calming**: Repeated follow-up actions such as tenant compare, finding, or run links MUST become visually secondary. The default focus in dense or compact mode MUST remain the compare state, not the link chrome.
|
||||||
- **FR-191-008 Filter density**: The page MUST show active filter count and active filter scope clearly while keeping the filter zone visually compact.
|
- **FR-191-008 Filter density**: The page MUST show active filter count and active filter scope clearly while keeping the filter zone visually compact.
|
||||||
- **FR-191-009 Heavy-filter workflow**: Policy type and other heavy multi-select filters MUST use an explicit apply/reset interaction instead of forcing a full matrix recompute on every click.
|
- **FR-191-009 Heavy-filter workflow**: Policy type and other heavy multi-select filters MUST use an explicit apply/reset interaction instead of forcing a full matrix recompute on every click.
|
||||||
- **FR-191-010 Policy type usability**: Policy type filtering MUST be faster than the current long checkbox stack, for example by searchability, type-to-find behavior, or another equally compact operator-first selector.
|
- **FR-191-010 Policy type usability**: Policy type filtering MUST replace the long checkbox stack with a searchable multi-select or an equivalent compact selector that supports type-to-find behavior and stays one compact control when closed.
|
||||||
- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST remain available but MUST be grouped and visually compressed so they do not displace the matrix in daily use.
|
- **FR-191-011 Legend compression**: State, freshness, and trust legends MUST default to one grouped support block with summary labels visible and detailed explanatory text hidden behind an explicit reveal so they do not displace the matrix in daily use.
|
||||||
- **FR-191-012 Honest status transitions**: The page MUST distinguish between active loading, background auto-refresh, and last-updated freshness so operators can tell whether the matrix is recalculating or simply polling for updates.
|
- **FR-191-012 Honest status transitions**: The page MUST distinguish between active loading, background auto-refresh, and last-updated freshness so operators can tell whether the matrix is recalculating or simply polling for updates.
|
||||||
- **FR-191-013 Last updated visibility**: The page MUST show a page-level or matrix-level freshness hint indicating when the currently rendered matrix data was last refreshed.
|
- **FR-191-013 Last updated visibility**: The page MUST show a page-level or matrix-level freshness hint indicating when the currently rendered matrix data was last refreshed.
|
||||||
- **FR-191-014 Visible-set truth preserved**: Dense and compact mode MUST preserve the visible-set-only semantics already defined in Spec 190 for all counts, subject breadth, and drilldowns.
|
- **FR-191-014 Visible-set truth preserved**: Dense and compact mode MUST preserve the visible-set-only semantics already defined in Spec 190 for all counts, subject breadth, and drilldowns.
|
||||||
@ -159,6 +167,13 @@ ### Functional Requirements
|
|||||||
- **FR-191-017 No new persistence**: This spec MUST NOT introduce a new matrix snapshot, portfolio report, stored view preference, or any other new persisted artifact.
|
- **FR-191-017 No new persistence**: This spec MUST NOT introduce a new matrix snapshot, portfolio report, stored view preference, or any other new persisted artifact.
|
||||||
- **FR-191-018 Automated regression coverage**: Automated coverage MUST prove mode selection, sticky dense layout, compact single-tenant layout, filter apply/reset behavior, legend compression, non-blocking refresh state, and preservation of existing drilldowns and RBAC semantics.
|
- **FR-191-018 Automated regression coverage**: Automated coverage MUST prove mode selection, sticky dense layout, compact single-tenant layout, filter apply/reset behavior, legend compression, non-blocking refresh state, and preservation of existing drilldowns and RBAC semantics.
|
||||||
|
|
||||||
|
## Measurable Acceptance Thresholds
|
||||||
|
|
||||||
|
- Dense auto mode is accepted only when a multi-tenant matrix render shows the first sticky subject row without scrolling past expanded legends or a long filter stack on a desktop operator viewport.
|
||||||
|
- Compact auto mode is accepted only when the RBAC-visible single-tenant edge case renders the compact result list instead of the dense grid while preserving visible-set-only counts and drilldown continuity.
|
||||||
|
- Staged filtering is accepted only when draft multi-select or sort changes do not redraw the matrix until the operator explicitly applies or resets them, and the active filter summary continues to describe the applied route state.
|
||||||
|
- Support-surface compression is accepted only when legends stay grouped behind an explicit reveal, passive auto-refresh remains visibly distinct from deliberate refresh, and last-updated context stays visible on the page.
|
||||||
|
|
||||||
## Non-Goals
|
## Non-Goals
|
||||||
|
|
||||||
- No change to baseline compare logic or evidence resolution
|
- No change to baseline compare logic or evidence resolution
|
||||||
@ -204,11 +219,13 @@ ## Definition of Done
|
|||||||
|
|
||||||
- the existing matrix route supports `auto`, `dense`, and `compact` presentation behavior,
|
- the existing matrix route supports `auto`, `dense`, and `compact` presentation behavior,
|
||||||
- multi-tenant auto mode renders a clearly denser matrix with a sticky subject column,
|
- multi-tenant auto mode renders a clearly denser matrix with a sticky subject column,
|
||||||
|
- the RBAC-scoped case where more than one tenant is assigned but only one tenant is visible resolves to compact mode while preserving visible-set-only counts and drilldowns,
|
||||||
- single-tenant auto mode renders a compact compare-list presentation instead of the current matrix-heavy layout,
|
- single-tenant auto mode renders a compact compare-list presentation instead of the current matrix-heavy layout,
|
||||||
- supporting context is visibly lighter than the matrix body,
|
- supporting context is visibly lighter than the matrix body,
|
||||||
- repeated per-cell or per-row actions no longer dominate the reading flow,
|
- repeated per-cell or per-row actions no longer dominate the reading flow,
|
||||||
- active filters are counted and heavy filters use an explicit apply/reset pattern,
|
- active filters are counted and heavy filters use an explicit apply/reset pattern,
|
||||||
|
- zero-result filtered states preserve the active mode and offer `Reset filters` as the single primary CTA,
|
||||||
- legends remain available but are grouped and visually compressed,
|
- legends remain available but are grouped and visually compressed,
|
||||||
- page-level refresh and last-updated signals are honest and non-blocking,
|
- page-level refresh and last-updated signals are honest and non-blocking,
|
||||||
- no compare logic, trust logic, freshness logic, or RBAC semantics have changed,
|
- no compare logic, trust logic, freshness logic, or RBAC semantics have changed,
|
||||||
- and focused feature plus browser coverage proves the new operator-density behavior.
|
- and focused feature plus browser coverage proves the new operator-density behavior.
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
# Tasks: Baseline Compare Matrix: High-Density Operator Mode
|
# Tasks: Baseline Compare Matrix: High-Density Operator Mode
|
||||||
|
|
||||||
**Input**: Design documents from `/specs/191-baseline-compare-operator-mode/`
|
**Input**: Design documents from `/specs/191-baseline-compare-operator-mode/`
|
||||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/baseline-compare-operator-mode.logical.openapi.yaml`
|
||||||
|
|
||||||
**Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route.
|
**Tests**: Tests are REQUIRED. Extend Pest feature coverage and browser smoke coverage around the existing matrix route.
|
||||||
**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, no new run-summary contract, and no new notification channel should be introduced.
|
**Operations**: This feature reuses existing `baseline_compare` run truth only. No new `OperationRun` type, run-summary contract, or notification channel should be introduced.
|
||||||
**RBAC**: Existing workspace and tenant visibility rules from Spec 190 remain authoritative. Tasks must preserve visible-set-only aggregation and existing `404` vs `403` behavior.
|
**RBAC**: Existing workspace and tenant visibility rules from Spec 190 remain authoritative. Tasks must preserve visible-set-only aggregation and existing `404` vs `403` behavior.
|
||||||
**Operator Surfaces**: The affected operator surface is the existing workspace baseline compare matrix route, with additive presentation changes only.
|
**Operator Surfaces**: The affected operator surface is the existing workspace baseline compare matrix route, with additive presentation changes only.
|
||||||
**Filament UI Action Surfaces**: The matrix page keeps explicit drilldown controls and forbidden row click. No destructive action is added.
|
**Filament UI Action Surfaces**: The matrix page keeps explicit drilldown controls and forbidden row click. No destructive action is added.
|
||||||
@ -12,28 +12,31 @@ # Tasks: Baseline Compare Matrix: High-Density Operator Mode
|
|||||||
|
|
||||||
**Organization**: Tasks are grouped by user story so each operator-density improvement can be implemented and verified independently.
|
**Organization**: Tasks are grouped by user story so each operator-density improvement can be implemented and verified independently.
|
||||||
|
|
||||||
## Phase 1: Setup (Presentation Seams)
|
## Phase 1: Setup (Spec and Acceptance Seams)
|
||||||
|
|
||||||
**Purpose**: Prepare focused acceptance seams for adaptive presentation work.
|
**Purpose**: Lock the implementation contract and acceptance seams before page behavior changes.
|
||||||
|
|
||||||
- [ ] T001 [P] Add presentation-mode acceptance scaffolds in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
- [X] T001 Finalize the UI Action Matrix, operator-surface assumptions, and measurable acceptance thresholds in `specs/191-baseline-compare-operator-mode/spec.md`
|
||||||
- [ ] T002 [P] Extend surface-contract guard coverage for calmer matrix actions in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
- [X] T002 [P] Reconcile the staged filter and presentation-mode interaction contract in `specs/191-baseline-compare-operator-mode/contracts/baseline-compare-operator-mode.logical.openapi.yaml`
|
||||||
|
- [X] T003 [P] Add acceptance scaffolds for multi-tenant, single-tenant, and staged-filter scenarios in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
||||||
|
- [X] T004 [P] Extend browser and action-surface guard seams in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php` and `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||||
|
|
||||||
**Checkpoint**: The test suite has clear seams for dense mode, compact mode, and action-noise expectations.
|
**Checkpoint**: The spec contract and test seams are ready for implementation work.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Operator-Density Contract)
|
## Phase 2: Foundational (Blocking Presentation Contract)
|
||||||
|
|
||||||
**Purpose**: Establish page-level presentation state and derived view metadata before reshaping the UI.
|
**Purpose**: Establish page-level presentation state and derived read models before reshaping dense and compact layouts.
|
||||||
|
|
||||||
**⚠️ CRITICAL**: No story work should begin until the presentation contract is stable.
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
- [ ] T003 Add `auto`, `dense`, and `compact` presentation state handling plus route persistence in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
- [X] T005 Add requested, resolved, and manual presentation-mode query handling plus staged filter state as request-scoped-only route state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||||
- [ ] T004 [P] Add derived density metadata for compact cell summaries and compact single-tenant summaries in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
- [X] T006 [P] Extend matrix bundle outputs for dense rows, compact results, support-surface state, and last-updated metadata in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
||||||
- [ ] T005 [P] Add page-level last-updated and staged-filter metadata in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
- [X] T007 [P] Add foundational builder coverage for requested or resolved mode, filter metadata, support-surface state, and unchanged compare state, trust, freshness, and severity outputs in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
||||||
|
- [X] T008 [P] Add foundational page coverage for mode resolution, route-state persistence, and derived-only non-persistence guarantees in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||||
|
|
||||||
**Checkpoint**: The page can resolve presentation mode and expose the supporting state required for dense and compact rendering.
|
**Checkpoint**: The page can resolve `auto`, `dense`, and `compact` mode and expose all derived state needed by the UI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -45,15 +48,16 @@ ## Phase 3: User Story 1 - Scan multi-tenant drift in dense mode (Priority: P1)
|
|||||||
|
|
||||||
### Tests for User Story 1
|
### Tests for User Story 1
|
||||||
|
|
||||||
- [ ] T006 [P] [US1] Add dense multi-tenant page coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
- [X] T009 [P] [US1] Add dense-mode assertions for auto resolution, sticky subject behavior, and compact cell hierarchy in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||||
- [ ] T007 [P] [US1] Extend browser smoke coverage for dense-mode scanning in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
- [X] T010 [P] [US1] Extend browser smoke coverage for dense-mode scanning and dense-mode drilldowns in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||||
|
|
||||||
### Implementation for User Story 1
|
### Implementation for User Story 1
|
||||||
|
|
||||||
- [ ] T008 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
- [X] T011 [US1] Render the dense multi-tenant matrix shell with sticky subject-column behavior in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||||
- [ ] T009 [US1] Reduce dense-cell chrome to compact state, trust, freshness, and attention signals in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
- [X] T012 [US1] Surface condensed dense-cell state, trust, freshness, and attention summaries in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||||
- [ ] T010 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
- [X] T013 [US1] Calm repeated cell and tenant actions into compact secondary affordances in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php` and `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||||
- [ ] T011 [US1] Run focused US1 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
- [X] T014 [US1] Preserve focused-subject and visible-set drilldown continuity for dense mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||||
|
- [X] T015 [US1] Run focused dense-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||||
|
|
||||||
**Checkpoint**: Multi-tenant scanning is visibly denser and the matrix body reads as the primary working surface.
|
**Checkpoint**: Multi-tenant scanning is visibly denser and the matrix body reads as the primary working surface.
|
||||||
|
|
||||||
@ -67,14 +71,15 @@ ## Phase 4: User Story 2 - Work a single visible tenant in compact mode (Priorit
|
|||||||
|
|
||||||
### Tests for User Story 2
|
### Tests for User Story 2
|
||||||
|
|
||||||
- [ ] T012 [P] [US2] Add compact single-tenant coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
- [X] T016 [P] [US2] Add compact single-tenant page assertions for auto-to-compact resolution, including the assigned-greater-than-visible RBAC edge case, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||||
|
- [X] T017 [P] [US2] Add compact single-tenant builder assertions for visible-set-only compact resolution and unchanged compare semantics in `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
||||||
|
|
||||||
### Implementation for User Story 2
|
### Implementation for User Story 2
|
||||||
|
|
||||||
- [ ] T013 [US2] Resolve auto-to-compact presentation behavior for one visible tenant in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
- [X] T018 [US2] Emit compact single-tenant result entries, compact drilldown metadata, and visible-set-only compact resolution when assigned tenants exceed visible tenants in `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
||||||
- [ ] T014 [US2] Render the compact single-tenant compare list in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
- [X] T019 [US2] Render the compact single-tenant compare list and reduced metadata shell in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||||
- [ ] T015 [US2] Preserve subject focus and drilldown continuity across compact-mode state in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
- [X] T020 [US2] Preserve manual override, subject focus, and drilldown continuity for compact mode in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||||
- [ ] T016 [US2] Run focused US2 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
- [X] T021 [US2] Run focused compact-mode verification in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`
|
||||||
|
|
||||||
**Checkpoint**: One-tenant viewing is materially shorter and calmer than the current matrix surface.
|
**Checkpoint**: One-tenant viewing is materially shorter and calmer than the current matrix surface.
|
||||||
|
|
||||||
@ -88,16 +93,17 @@ ## Phase 5: User Story 3 - Use filters, legends, and status surfaces without los
|
|||||||
|
|
||||||
### Tests for User Story 3
|
### Tests for User Story 3
|
||||||
|
|
||||||
- [ ] T017 [P] [US3] Add filter apply/reset and legend-compaction coverage in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
- [X] T022 [P] [US3] Add staged-filter, legend-compaction, refresh-cue, and zero-result empty-state assertions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||||
- [ ] T018 [P] [US3] Add non-blocking refresh and last-updated browser coverage in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
- [X] T023 [P] [US3] Add browser smoke coverage for apply/reset filters, passive auto-refresh cues, and filtered zero-result empty states in `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||||
|
|
||||||
### Implementation for User Story 3
|
### Implementation for User Story 3
|
||||||
|
|
||||||
- [ ] T019 [US3] Convert heavy matrix filters to a staged apply/reset workflow in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
- [X] T024 [US3] Implement staged heavy-filter draft, apply, reset, and zero-result empty-state behavior in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||||
- [ ] T020 [US3] Replace the current policy-type control with a faster compact operator-first selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
- [X] T025 [US3] Replace the long policy-type control with a searchable compact selector in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`
|
||||||
- [ ] T021 [US3] Group or collapse legends and lighten supporting context hierarchy in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
- [X] T026 [US3] Render applied-versus-draft filter summaries, one grouped collapsed legend block, and compressed support context in `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||||
- [ ] T022 [US3] Render page-level last-updated, polling, and manual-refresh signals without blocking the matrix in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
- [X] T027 [US3] Render honest manual-refresh, passive polling, and last-updated cues in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||||
- [ ] T023 [US3] Run focused US3 verification against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php` and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
- [X] T028 [US3] Keep calmer actions and forbidden row-click behavior enforced in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||||
|
- [X] T029 [US3] Run focused support-surface verification, including zero-result empty-state behavior, in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||||
|
|
||||||
**Checkpoint**: Filters, legends, and status surfaces support the operator without visually competing with the matrix.
|
**Checkpoint**: Filters, legends, and status surfaces support the operator without visually competing with the matrix.
|
||||||
|
|
||||||
@ -107,9 +113,10 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
|||||||
|
|
||||||
**Purpose**: Finalize copy, formatting, and the focused verification pack.
|
**Purpose**: Finalize copy, formatting, and the focused verification pack.
|
||||||
|
|
||||||
- [ ] T024 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
- [X] T030 [P] Review `auto`, `dense`, `compact`, `last updated`, and action-copy vocabulary in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`
|
||||||
- [ ] T025 [P] Run formatting with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
- [X] T031 [P] Verify shared badge semantics remain centralized in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/resources/views/filament/pages/baseline-compare-matrix.blade.php`, and `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`
|
||||||
- [ ] T026 Run the focused verification pack against `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
- [X] T032 [P] Run formatting for changed implementation files in `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php` and `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`
|
||||||
|
- [X] T033 Run the focused verification pack and confirm no compare-truth or persistence regressions in `apps/platform/tests/Feature/Filament/BaselineCompareMatrixPageTest.php`, `apps/platform/tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php`, `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`, and `apps/platform/tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -117,30 +124,57 @@ ## Dependencies & Execution Order
|
|||||||
|
|
||||||
### Phase Dependencies
|
### Phase Dependencies
|
||||||
|
|
||||||
- **Setup (Phase 1)**: starts immediately.
|
- **Setup (Phase 1)**: No dependencies. Start immediately.
|
||||||
- **Foundational (Phase 2)**: depends on Setup and blocks presentation work.
|
- **Foundational (Phase 2)**: Depends on Phase 1. Blocks all user-story implementation.
|
||||||
- **US1 (Phase 3)**: depends on Phase 2 and is the MVP.
|
- **User Story 1 (Phase 3)**: Depends on Phase 2. This is the MVP slice.
|
||||||
- **US2 (Phase 4)**: depends on Phase 2 and can follow US1 once the page contract is stable.
|
- **User Story 2 (Phase 4)**: Depends on Phase 2. Can proceed after the shared presentation contract is stable.
|
||||||
- **US3 (Phase 5)**: depends on Phase 2 and should land after dense and compact structure are in place.
|
- **User Story 3 (Phase 5)**: Depends on Phase 2. Should land after the dense and compact layout branches exist.
|
||||||
- **Polish (Phase 6)**: depends on the desired user stories being complete.
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1**: Independent after Phase 2 and should be delivered first.
|
||||||
|
- **US2**: Independent after Phase 2, but it reuses the shared presentation contract from US1-era foundational work.
|
||||||
|
- **US3**: Independent after Phase 2, but it should align with the final dense and compact layout structure.
|
||||||
|
|
||||||
### Within Each User Story
|
### Within Each User Story
|
||||||
|
|
||||||
- Add or extend the story tests first.
|
- Tests for that story should be written and made to fail before implementation.
|
||||||
- Land page-state changes before view-branching where possible.
|
- Builder and page state updates should land before Blade branching that depends on them.
|
||||||
- Keep each story independently shippable and verifiable.
|
- Each story must remain independently testable when finished.
|
||||||
|
|
||||||
### Parallel Opportunities
|
## Parallel Execution Examples
|
||||||
|
|
||||||
- `T001` and `T002` can run in parallel.
|
### User Story 1
|
||||||
- `T004` and `T005` can run in parallel after `T003` defines the presentation contract.
|
|
||||||
- Within US1, `T006` and `T007` can run in parallel before `T008` through `T010`.
|
- Run `T009` and `T010` in parallel because they touch separate test files.
|
||||||
- Within US3, `T017` and `T018` can run in parallel before `T019` through `T022`.
|
- After `T011` lands, `T012` can proceed while `T014` is prepared if the route-state contract is already stable.
|
||||||
|
|
||||||
|
### User Story 2
|
||||||
|
|
||||||
|
- Run `T016` and `T017` in parallel because they cover separate test layers.
|
||||||
|
- `T018` should land before `T019` because the compact Blade path depends on compact result entries.
|
||||||
|
|
||||||
|
### User Story 3
|
||||||
|
|
||||||
|
- Run `T022` and `T023` in parallel because they touch separate test files.
|
||||||
|
- `T024` and `T025` can be split between staged filter flow and selector compaction if coordinated on `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`.
|
||||||
|
|
||||||
## Implementation Strategy
|
## Implementation Strategy
|
||||||
|
|
||||||
1. Lock the presentation contract and route state first.
|
### MVP First
|
||||||
2. Deliver dense multi-tenant mode as the MVP operator gain.
|
|
||||||
3. Deliver compact single-tenant mode as the adaptive counterpart.
|
1. Finish Setup and Foundational work.
|
||||||
4. Compress filters, legends, and status surfaces last so they match the final page structure.
|
2. Deliver US1 dense multi-tenant mode as the MVP operator gain.
|
||||||
5. Finish with copy review, formatting, and the focused verification pack.
|
3. Verify US1 independently before moving on.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Add US2 compact single-tenant mode on top of the shared presentation contract.
|
||||||
|
2. Add US3 filter, legend, and refresh-surface compression once both layout branches are stable.
|
||||||
|
3. Finish with copy review, formatting, and the focused verification pack.
|
||||||
|
|
||||||
|
### Validation Rule
|
||||||
|
|
||||||
|
1. Do not mark a story complete until its focused verification task passes.
|
||||||
|
2. Keep the existing Spec 190 truth, RBAC semantics, and drilldown continuity intact while implementing each story.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user