Fix findings view toggle reactivity
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
# Sprint 20260307-017 - FE Findings View Toggle Reactivity
|
||||||
|
|
||||||
|
## Topic & Scope
|
||||||
|
- Repair the live `/security/findings` view toggle so `Diff` and `Detail` actually switch the rendered findings workspace.
|
||||||
|
- Keep the existing URL override semantics (`?view=diff|detail`) while making the default no-query flow react to the persisted user preference signal.
|
||||||
|
- Add focused Angular coverage and replay the findings toggle with live Playwright on `https://stella-ops.local`.
|
||||||
|
- Working directory: `src/Web/StellaOps.Web`.
|
||||||
|
- Expected evidence: focused Angular tests, live Playwright on `/security/findings`, and sprint execution log updates.
|
||||||
|
|
||||||
|
## Dependencies & Concurrency
|
||||||
|
- Follows the mission-control scope hydration cleanup from `SPRINT_20260307_016`; the findings page is one of the downstream surfaces reached by those flows.
|
||||||
|
- Safe parallelism: stay inside `src/Web/StellaOps.Web` plus this sprint file; do not touch unrelated settings/topbar/user-menu/sidebar/search work already in progress from other agents.
|
||||||
|
- Scope is limited to findings view-switch behavior, not a broader findings feature rewrite.
|
||||||
|
|
||||||
|
## Documentation Prerequisites
|
||||||
|
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||||
|
- `src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts`
|
||||||
|
- `src/Web/StellaOps.Web/src/app/shared/components/findings-view-toggle/findings-view-toggle.component.ts`
|
||||||
|
|
||||||
|
## Delivery Tracker
|
||||||
|
|
||||||
|
### FE-FIND-001 - Reproduce inert findings view toggle
|
||||||
|
Status: DONE
|
||||||
|
Dependency: none
|
||||||
|
Owners: QA
|
||||||
|
Task description:
|
||||||
|
- Use live authenticated Playwright on `/security/findings` to verify whether the `Diff` / `Detail` toggle changes the rendered workspace.
|
||||||
|
- Reduce the defect to a concrete component-state problem rather than a broad findings-page complaint.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Live Playwright proves the toggle is inert or otherwise broken.
|
||||||
|
- [x] The issue is narrowed to a specific state/reactivity gap in the findings container.
|
||||||
|
|
||||||
|
### FE-FIND-002 - Make findings container react to view preference changes
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-FIND-001
|
||||||
|
Owners: Developer
|
||||||
|
Task description:
|
||||||
|
- Update the findings container so the rendered view reacts to the preference signal when no explicit URL override is present.
|
||||||
|
- Preserve explicit URL override semantics for `?view=diff|detail`.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Clicking `Detail` on `/security/findings` switches the rendered workspace to the detail list.
|
||||||
|
- [x] Clicking `Diff` switches back to the comparison workspace.
|
||||||
|
- [x] URL overrides remain authoritative when explicitly present.
|
||||||
|
|
||||||
|
### FE-FIND-003 - Add focused Angular regression coverage
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-FIND-002
|
||||||
|
Owners: Test Automation
|
||||||
|
Task description:
|
||||||
|
- Add focused tests under `src/app/core/testing` so they run in the targeted Angular slice.
|
||||||
|
- Assert both the reactive preference-driven switch and the URL override precedence.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Focused Angular tests fail before the fix and pass after it.
|
||||||
|
- [x] Tests assert concrete rendered outcomes, not just internal method calls.
|
||||||
|
|
||||||
|
### FE-FIND-004 - Replay the findings toggle live after the patch
|
||||||
|
Status: DONE
|
||||||
|
Dependency: FE-FIND-003
|
||||||
|
Owners: QA
|
||||||
|
Task description:
|
||||||
|
- Rebuild the Web bundle, sync it into the live compose frontend volume, and re-run the findings toggle with Playwright.
|
||||||
|
- Confirm the rendered page actually changes between diff and detail on the live stack.
|
||||||
|
|
||||||
|
Completion criteria:
|
||||||
|
- [x] Live Playwright confirms `Detail` shows the detail findings list.
|
||||||
|
- [x] Live Playwright confirms `Diff` returns to the comparison workspace.
|
||||||
|
|
||||||
|
## Execution Log
|
||||||
|
| Date (UTC) | Update | Owner |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 2026-03-07 | Sprint created after live Playwright on `/security/findings?tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d` showed that clicking `Detail` left the page in diff mode (`Comparing: Select baseline` stayed visible and no detail-list content appeared). | QA |
|
||||||
|
| 2026-03-07 | Updated `FindingsContainerComponent` so the rendered view reacts to the `ViewPreferenceService` signal while keeping explicit `?view=` query overrides authoritative; added focused coverage in `src/app/core/testing/findings-container.component.spec.ts`. | Developer |
|
||||||
|
| 2026-03-07 | Focused Angular verification passed: `npx ng test --watch=false --include src/app/core/testing/findings-container.component.spec.ts` (2/2). | Test Automation |
|
||||||
|
| 2026-03-07 | Rebuilt the Web bundle, synced `dist/stellaops-web/browser` into the live `compose_console-dist` volume, and re-ran live Playwright on `/security/findings`. `Detail` now removes the diff baseline workspace and renders finding rows (`frontend-ui 4.0.1`, `CVE-2026-8003`), while `Diff` restores the comparison workspace. | QA |
|
||||||
|
|
||||||
|
## Decisions & Risks
|
||||||
|
- Decision: fix the findings container reactivity instead of patching the toggle component, because the toggle correctly writes the preference but the container snapshots that preference only once during startup.
|
||||||
|
- Risk: `/security/findings?view=detail` is an explicit URL override path and must remain authoritative even after the container becomes reactive to preference changes.
|
||||||
|
- Decision: keep the toggle fix scoped to rendering reactivity; it does not rewrite the findings route URL when the preference changes because the existing product contract already treats `view` as an explicit override, not a required persistence mechanism.
|
||||||
|
|
||||||
|
## Next Checkpoints
|
||||||
|
- 2026-03-07: land the findings toggle reactivity fix and focused tests.
|
||||||
|
- 2026-03-07: replay `/security/findings` live with Playwright and continue the page/action sweep.
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject, of } from 'rxjs';
|
||||||
|
|
||||||
|
import { FindingsContainerComponent } from '../../features/findings/container/findings-container.component';
|
||||||
|
import { CompareService } from '../../features/compare/services/compare.service';
|
||||||
|
import { ViewPreferenceService, FindingsViewMode } from '../services/view-preference.service';
|
||||||
|
import { MockScoringApi, SCORING_API } from '../services/scoring.service';
|
||||||
|
|
||||||
|
describe('FindingsContainerComponent', () => {
|
||||||
|
let fixture: ComponentFixture<FindingsContainerComponent>;
|
||||||
|
let queryParamMap$: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||||
|
let preferenceSignal: ReturnType<typeof signal<FindingsViewMode>>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
queryParamMap$ = new BehaviorSubject(convertToParamMap({}));
|
||||||
|
preferenceSignal = signal<FindingsViewMode>('diff');
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [FindingsContainerComponent, NoopAnimationsModule],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ViewPreferenceService,
|
||||||
|
useValue: {
|
||||||
|
viewMode: preferenceSignal.asReadonly(),
|
||||||
|
getViewMode: () => preferenceSignal(),
|
||||||
|
setViewMode: (mode: FindingsViewMode) => preferenceSignal.set(mode),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: CompareService,
|
||||||
|
useValue: {
|
||||||
|
getBaselineRecommendations: () =>
|
||||||
|
of({
|
||||||
|
selectedDigest: 'baseline-123',
|
||||||
|
selectionReason: 'Last Green Build',
|
||||||
|
alternatives: [],
|
||||||
|
autoSelectEnabled: true,
|
||||||
|
}),
|
||||||
|
computeDelta: () =>
|
||||||
|
of({
|
||||||
|
categories: [
|
||||||
|
{ id: 'added', name: 'Added', icon: 'add', added: 1, removed: 0, changed: 0 },
|
||||||
|
],
|
||||||
|
items: [],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: SCORING_API,
|
||||||
|
useClass: MockScoringApi,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
paramMap: of(convertToParamMap({ scanId: 'test-scan-123' })),
|
||||||
|
queryParamMap: queryParamMap$,
|
||||||
|
snapshot: {
|
||||||
|
paramMap: convertToParamMap({ scanId: 'test-scan-123' }),
|
||||||
|
queryParamMap: convertToParamMap({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FindingsContainerComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches from diff to detail when the persisted preference changes without a URL override', async () => {
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Comparing:');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('backend-api');
|
||||||
|
|
||||||
|
preferenceSignal.set('detail');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('backend-api');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Comparing:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the explicit URL override authoritative over the persisted preference', async () => {
|
||||||
|
queryParamMap$.next(convertToParamMap({ view: 'diff' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
preferenceSignal.set('detail');
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.viewMode()).toBe('diff');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Comparing:');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('backend-api');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -197,8 +197,8 @@ export class FindingsContainerComponent implements OnInit {
|
|||||||
private readonly viewPref = inject(ViewPreferenceService);
|
private readonly viewPref = inject(ViewPreferenceService);
|
||||||
private readonly compareService = inject(CompareService);
|
private readonly compareService = inject(CompareService);
|
||||||
|
|
||||||
// View mode: URL param > user preference > default (diff)
|
// View mode: explicit URL override > reactive user preference
|
||||||
readonly viewMode = signal<FindingsViewMode>('diff');
|
readonly viewMode = computed<FindingsViewMode>(() => this.urlViewMode() ?? this.viewPref.viewMode());
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
@@ -209,6 +209,11 @@ export class FindingsContainerComponent implements OnInit {
|
|||||||
// Delta summary for diff view
|
// Delta summary for diff view
|
||||||
readonly deltaSummary = signal<{ added: number; removed: number; changed: number } | null>(null);
|
readonly deltaSummary = signal<{ added: number; removed: number; changed: number } | null>(null);
|
||||||
|
|
||||||
|
private readonly urlViewMode = toSignal(
|
||||||
|
this.route.queryParamMap.pipe(map((params) => this.readViewMode(params.get('view')))),
|
||||||
|
{ initialValue: this.readViewMode(this.route.snapshot.queryParamMap.get('view')) }
|
||||||
|
);
|
||||||
|
|
||||||
// Current scan ID resolved from route param, query param, or deterministic fallback.
|
// Current scan ID resolved from route param, query param, or deterministic fallback.
|
||||||
private readonly scanId = toSignal(
|
private readonly scanId = toSignal(
|
||||||
this.route.paramMap.pipe(
|
this.route.paramMap.pipe(
|
||||||
@@ -227,25 +232,9 @@ export class FindingsContainerComponent implements OnInit {
|
|||||||
);
|
);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.initializeViewMode();
|
|
||||||
this.loadData();
|
this.loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeViewMode(): void {
|
|
||||||
// Check URL override first
|
|
||||||
const urlView = this.route.snapshot.queryParamMap.get('view');
|
|
||||||
if (urlView === 'diff' || urlView === 'detail') {
|
|
||||||
this.viewMode.set(urlView);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to user preference
|
|
||||||
this.viewMode.set(this.viewPref.getViewMode());
|
|
||||||
|
|
||||||
// Subscribe to preference changes
|
|
||||||
// Note: Using effect would be cleaner but keeping it simple here
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadData(): void {
|
private loadData(): void {
|
||||||
const scanId = this.scanId();
|
const scanId = this.scanId();
|
||||||
if (!scanId) return;
|
if (!scanId) return;
|
||||||
@@ -278,4 +267,8 @@ export class FindingsContainerComponent implements OnInit {
|
|||||||
// Navigate to finding detail or open drawer
|
// Navigate to finding detail or open drawer
|
||||||
console.log('Selected finding:', finding.id);
|
console.log('Selected finding:', finding.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readViewMode(raw: string | null): FindingsViewMode | null {
|
||||||
|
return raw === 'diff' || raw === 'detail' ? raw : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user