diff --git a/docs/implplan/SPRINT_20260307_017_FE_findings_view_toggle_reactivity.md b/docs/implplan/SPRINT_20260307_017_FE_findings_view_toggle_reactivity.md new file mode 100644 index 000000000..ccf10acdc --- /dev/null +++ b/docs/implplan/SPRINT_20260307_017_FE_findings_view_toggle_reactivity.md @@ -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. diff --git a/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts new file mode 100644 index 000000000..afaa5b964 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/testing/findings-container.component.spec.ts @@ -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; + let queryParamMap$: BehaviorSubject>; + let preferenceSignal: ReturnType>; + + beforeEach(async () => { + queryParamMap$ = new BehaviorSubject(convertToParamMap({})); + preferenceSignal = signal('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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts index 6ddab0e5b..75b6e2b1d 100644 --- a/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/findings/container/findings-container.component.ts @@ -197,8 +197,8 @@ export class FindingsContainerComponent implements OnInit { private readonly viewPref = inject(ViewPreferenceService); private readonly compareService = inject(CompareService); - // View mode: URL param > user preference > default (diff) - readonly viewMode = signal('diff'); + // View mode: explicit URL override > reactive user preference + readonly viewMode = computed(() => this.urlViewMode() ?? this.viewPref.viewMode()); // Loading state readonly loading = signal(false); @@ -209,6 +209,11 @@ export class FindingsContainerComponent implements OnInit { // Delta summary for diff view 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. private readonly scanId = toSignal( this.route.paramMap.pipe( @@ -227,25 +232,9 @@ export class FindingsContainerComponent implements OnInit { ); ngOnInit(): void { - this.initializeViewMode(); 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 { const scanId = this.scanId(); if (!scanId) return; @@ -278,4 +267,8 @@ export class FindingsContainerComponent implements OnInit { // Navigate to finding detail or open drawer console.log('Selected finding:', finding.id); } + + private readViewMode(raw: string | null): FindingsViewMode | null { + return raw === 'diff' || raw === 'detail' ? raw : null; + } }