fix(work): end a same-change focus when another area is opened
Deploy / Test, build & deploy (push) Successful in 8m50s Details

Switching areas through the main navigation kept the related-findings group focus, so Work still showed only the group afterwards. The navigation now releases the focus, restoring the previous Work view state, and keeps the chosen area.
This commit is contained in:
Binh 2026-10-14 13:53:10 +02:00
parent 529439dfa9
commit 0c7fa2cf6c
5 changed files with 124 additions and 3 deletions

View File

@ -1,5 +1,11 @@
# ASSESSOR – Umsetzungsplan # ASSESSOR – Umsetzungsplan
## Same-change focus ends on area switch (2026-10-14, Phase 7 Presentation)
Current user release: Presentation, Core Tests. User bug report: after clicking a same-change ring in Work and switching to another area, Work kept showing only the focused group. User decision: any switch to another area through the main navigation ends the focus, restores the preceding Work filters, sort, selection, view and scroll, and keeps the chosen area open (the focus restore no longer returns to its starting area in that case). The ring, `Restore previous view`, the Accepted Risks entry into Work and a click on the already active area keep their behavior. Permanent synthetic Light/Dark interaction test `tests/features/related-findings-area-switch.test.tsx` renders the real App and focus provider; the pinned source assertion in `work-header-layout.test.ts` follows the new navigation handler. Verified: the new test fails on the previous navigation code and passes with the fix; all 2563 tests, the import gate and both production builds pass. No version change in this step (Platform not released).
Change log 2026-10-14: a main-navigation area switch ends an active same-change focus.
## Rebase onto Michael 7471afc: Client Info repeat-visit cache and test alignment (2026-10-14, Phase 7 Presentation) ## Rebase onto Michael 7471afc: Client Info repeat-visit cache and test alignment (2026-10-14, Phase 7 Presentation)
Current user consent: Presentation, Core Tests. After rebasing the branch onto Michael's `7471afc` (Security export, Work Packages tab), the Client Info repeat-visit check read 43 record fields instead of 0: the Overview (current source) and Security (no source filter) analyses of the same sheet overwrote each other's single cache slot, and the Security view, its GPO gap counts and its group sort were recomputed on every remount. The analysis cache now keeps up to three source-filter entries per sheet, and the Security view and its sorted groups are cached by their analysis/record identity; counts, verdicts and drilldowns are unchanged. Test alignment by user decision: DB Structure cards may use `border-radius: 0` or the shared `--tile-radius` token (the AGENTS tile-radius wording is unchanged), and the Client Info action test expects Management Report, Technical Report and HTML Security Export in the Engines-blue role. `ClientInfoTabs` keeps `lazy` (unmount while hidden) and adds `retain` for the Work Packages tab. No version change; verified with `npm run test:imports`, all 2561 vitest tests and both production builds. Open: `tests/design-language-check.mjs` rejects the inline bar widths in `src/features/comparison/ComparisonTab.tsx` from 2.27.0. Current user consent: Presentation, Core Tests. After rebasing the branch onto Michael's `7471afc` (Security export, Work Packages tab), the Client Info repeat-visit check read 43 record fields instead of 0: the Overview (current source) and Security (no source filter) analyses of the same sheet overwrote each other's single cache slot, and the Security view, its GPO gap counts and its group sort were recomputed on every remount. The analysis cache now keeps up to three source-filter entries per sheet, and the Security view and its sorted groups are cached by their analysis/record identity; counts, verdicts and drilldowns are unchanged. Test alignment by user decision: DB Structure cards may use `border-radius: 0` or the shared `--tile-radius` token (the AGENTS tile-radius wording is unchanged), and the Client Info action test expects Management Report, Technical Report and HTML Security Export in the Engines-blue role. `ClientInfoTabs` keeps `lazy` (unmount while hidden) and adds `retain` for the Work Packages tab. No version change; verified with `npm run test:imports`, all 2561 vitest tests and both production builds. Open: `tests/design-language-check.mjs` rejects the inline bar widths in `src/features/comparison/ComparisonTab.tsx` from 2.27.0.

View File

@ -97,6 +97,12 @@ export function App() {
if (!hasIncomingFilter) app.resetFilters(); if (!hasIncomingFilter) app.resetFilters();
openWork(); openWork();
}, [app.actionOnly, app.facetFilters, app.pathFindingIds, app.resetFilters, app.search, openWork]); }, [app.actionOnly, app.facetFilters, app.pathFindingIds, app.resetFilters, app.search, openWork]);
// Switching areas ends a same-change focus. Its restore returns to the starting area,
// so the chosen area is applied afterwards and wins within the same update.
const navigateArea = (area: AppArea) => {
if (area !== activeArea) related?.release();
if (area === 'work') openWorkDefault(); else setActiveArea(area);
};
const marked = useMemo(() => Object.values(reviews).filter(review => review.include).length, [reviews]); const marked = useMemo(() => Object.values(reviews).filter(review => review.include).length, [reviews]);
@ -165,7 +171,7 @@ export function App() {
<ActivityCenter/> <ActivityCenter/>
<main> <main>
<aside><div className="app-sidebar"> <aside><div className="app-sidebar">
<nav className="sidebar-primary-actions" aria-label="Main navigation">{NAV_AREAS.map(([area, label]) => <button className={`sidebar-tab-btn${activeArea === area ? ' active' : ''}`} key={area} type="button" disabled={area !== 'data-hub' && area !== 'db-structure' && !targetProjectId} onClick={() => area === 'work' ? openWorkDefault() : setActiveArea(area)}><AreaIcon area={area}/><span>{label}</span></button>)}</nav> <nav className="sidebar-primary-actions" aria-label="Main navigation">{NAV_AREAS.map(([area, label]) => <button className={`sidebar-tab-btn${activeArea === area ? ' active' : ''}`} key={area} type="button" disabled={area !== 'data-hub' && area !== 'db-structure' && !targetProjectId} onClick={() => navigateArea(area)}><AreaIcon area={area}/><span>{label}</span></button>)}</nav>
{activeArea === 'work' && <SidebarSecurityDomains sheet={activeSheet}/>} {activeArea === 'work' && <SidebarSecurityDomains sheet={activeSheet}/>}
{/* Die Sidebar traegt ausschliesslich Navigation. Der Serverzustand steht {/* Die Sidebar traegt ausschliesslich Navigation. Der Serverzustand steht
einheitlich als kompakte Rot-/Gruen-Anzeige im Bereichsheader. */} einheitlich als kompakte Rot-/Gruen-Anzeige im Bereichsheader. */}

View File

@ -14,6 +14,8 @@ interface GroupContext {
selection?: Selection; selection?: Selection;
toggle(group: RelatedFindingGroup, recordId: string): void; toggle(group: RelatedFindingGroup, recordId: string): void;
showAllInWork(): void; showAllInWork(): void;
/** Ends an active focus and restores the preceding view state; a navigating caller applies its own area afterwards. */
release(): void;
registerView(capture: () => () => void): () => void; registerView(capture: () => () => void): () => void;
} }
const Context = createContext<GroupContext | undefined>(undefined); const Context = createContext<GroupContext | undefined>(undefined);
@ -120,7 +122,8 @@ export function RelatedFindingsProvider({ children }: PropsWithChildren) {
facetFilters: {}, pathFindingIds: recordIds, actionOnly: false, selectedRecordId: recordIds[0], facetFilters: {}, pathFindingIds: recordIds, actionOnly: false, selectedRecordId: recordIds[0],
sortColumn: app.sortColumn, sortDirection: app.sortDirection, titleColumn: app.titleColumn }); sortColumn: app.sortColumn, sortDirection: app.sortDirection, titleColumn: app.titleColumn });
}; };
return <Context.Provider value={{ groups, selection: current, toggle, registerView, showAllInWork }}>{children}</Context.Provider>; const release = () => { if (current) finish(); };
return <Context.Provider value={{ groups, selection: current, toggle, registerView, showAllInWork, release }}>{children}</Context.Provider>;
} }
export function RelatedFindingRing({ record, inline = false, onGroupOpen }: { record: FindingRecord; inline?: boolean; onGroupOpen?(): void }) { export function RelatedFindingRing({ record, inline = false, onGroupOpen }: { record: FindingRecord; inline?: boolean; onGroupOpen?(): void }) {

View File

@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, expect, it, vi } from 'vitest';
const appState = vi.hoisted(() => ({ current: {} as Record<string, unknown> }));
vi.mock('../../src/app/AppProvider', () => ({
useApp: () => appState.current,
useOptionalApp: () => appState.current,
RetainedAppState: ({ children }: { children: unknown }) => children
}));
vi.mock('../../src/remediation/use-work-packages', () => ({ useWorkPackages: vi.fn() }));
vi.mock('../../src/features/project-import/DataHub', () => ({ DataHub: () => <div>Data Hub</div> }));
vi.mock('../../src/features/dashboard/DashboardPanel', () => ({ DashboardPanel: () => <div data-testid="client-info">Synthetic Client Info</div> }));
vi.mock('../../src/features/db-structure/DbStructurePanel', () => ({ DbStructurePanel: () => null }));
vi.mock('../../src/features/results-browser/ResultsBrowser', () => ({ ResultsBrowser: () => null }));
vi.mock('../../src/features/search-filter/SearchFilterBar', () => ({
SearchFilterBar: () => null, SearchTextControl: () => null, SheetAndSearchControls: () => null, WorkTimeslotControl: () => null
}));
vi.mock('../../src/features/search-filter/use-filtered-records', () => ({ useFilteredRecords: () => [] }));
// Work stub: the real ring and focus provider decide what is shown; the stub only reports it.
vi.mock('../../src/features/dataset-viewer/DatasetViewer', async () => {
const related = await import('../../src/features/related-findings/RelatedFindings');
return { DatasetViewer: () => {
const context = related.useRelatedFindings();
const sheet = (appState.current.activeSheet as { analysis: { records: Array<{ id: string; title: string }> } });
const shown = context?.selection ? sheet.analysis.records.filter(record => context.selection!.group.recordIds.includes(record.id)) : sheet.analysis.records;
return <div data-testid="work" data-focused={Boolean(context?.selection) || undefined}>
{shown.map(record => <article key={record.id} className="record-card">{record.title}<related.RelatedFindingRing record={record as never}/></article>)}
</div>;
} };
});
vi.mock('../../src/features/mitre-graph/ObsidianGraphView', () => ({ ObsidianGraphView: () => null }));
vi.mock('../../src/features/actionlist/ActionlistControls', () => ({ ActionlistControls: () => null }));
vi.mock('../../src/features/import-workflow/ImportWorkflowDialog', () => ({ ImportWorkflowDialog: () => null }));
vi.mock('../../src/features/sidebar/SidebarSecurityDomains', () => ({ SidebarSecurityDomains: () => null }));
vi.mock('../../src/features/view-bookmarks/ViewBookmarks', () => ({ ViewBookmarks: () => null }));
vi.mock('../../src/features/mitre-graph/PathfinderFilterTrail', () => ({ PathfinderFilterTrail: () => null }));
vi.mock('../../src/features/about/AboutDialog', () => ({ AboutDialog: () => null }));
import { App } from '../../src/app/App';
import { RelatedFindingsProvider } from '../../src/features/related-findings/RelatedFindings';
import { useWorkPackages } from '../../src/remediation/use-work-packages';
import { workRecordKey } from '../../src/remediation/work-package-index';
import { makeSheet } from '../../src/importers/sheet-factory';
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks(); delete document.documentElement.dataset.theme; });
it.each(['dark', 'light'])('ends a same-change focus when another area is opened and stays in that area (%s)', async theme => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0));
vi.stubGlobal('cancelAnimationFrame', clearTimeout);
document.documentElement.dataset.theme = theme;
const sheet = makeSheet('Synthetic', [['Name', 'Status', 'Severity'],
['Synthetic template allows requester SAN', 'Failed', 'High'],
['Synthetic authentication template subject can be modified', 'Failed', 'High'],
['Synthetic unrelated finding', 'Failed', 'Low']], '', 'synthetic.csv', { isProjectSheet: true });
const records = sheet.analysis.records;
records.forEach((record, index) => { record.findingOccurrenceId = `synthetic-occurrence-${index}`; });
const pkg = { id: 'package', code: 'WP-01', title: 'Synthetic', clusterLabel: 'Synthetic', facts: { tasks: 1, findings: 2 },
tasks: [{ id: 'same-template-change', redundant: true, findingKeys: records.slice(0, 2).map(workRecordKey) }] };
vi.mocked(useWorkPackages).mockReturnValue({ status: 'ready', index: { packages: [pkg], packageFor: () => pkg,
unassigned: () => false, facet: { id: 'remediation:work-package', label: 'Work Package', values: [], valuesFor: () => [] } } } as never);
const applyViewBookmark = vi.fn();
const resetFilters = vi.fn();
const dataset = { sheets: [sheet], profileId: 'generic', fileName: 'Synthetic' };
appState.current = {
dataset, activeSheet: sheet, activeSheetId: sheet.id, reviews: {}, snapshot: () => null, theme, setTheme: vi.fn(),
delimiter: ',', setDelimiter: vi.fn(), view: 'records', setView: vi.fn(), actionOnly: false,
sidebarCollapsed: false, toggleSidebar: vi.fn(), toggleWorkspace: vi.fn(), backendStatus: 'connected',
targetProjectId: 'synthetic-project', activeScanRunIds: ['synthetic-run'], activeAssessmentIds: ['synthetic-slot'],
workScopeId: 'synthetic-scope', search: 'synthetic search', statusFilter: 'deviation', sortColumn: undefined, sortDirection: 'asc',
titleColumn: undefined, facetFilters: {}, pathFindingIds: [], selectedRecordId: records[2].id,
resetFilters, applyViewBookmark, setSelectedRecord: vi.fn(),
setPathFindingIds: vi.fn(), setStatusFilter: vi.fn(), setFacetFilter: vi.fn(), setActiveSheet: vi.fn(),
openProject: vi.fn(), setTargetProjectId: vi.fn(), importWorkflow: { phase: 'idle' }
};
const container = document.createElement('div'); document.body.append(container);
const root = createRoot(container);
const nav = (label: string) => [...container.querySelectorAll<HTMLButtonElement>('.sidebar-tab-btn')].find(button => button.textContent === label)!;
const work = () => container.querySelector<HTMLElement>('[data-testid="work"]');
try {
await act(async () => root.render(<RelatedFindingsProvider><App/></RelatedFindingsProvider>));
await act(async () => nav('Work').click());
expect(work()!.querySelectorAll('.record-card')).toHaveLength(3);
await act(async () => work()!.querySelector<HTMLButtonElement>('.related-finding-ring')!.click());
expect(work()!.dataset.focused).toBe('true');
expect(work()!.querySelectorAll('.record-card')).toHaveLength(2);
await act(async () => nav('Client Info').click());
await act(async () => new Promise(resolve => setTimeout(resolve, 0)));
// The focus ends, the previous Work state is restored and the chosen area stays open.
expect(applyViewBookmark).toHaveBeenCalledOnce();
expect(applyViewBookmark.mock.calls[0][0]).toMatchObject({ search: 'synthetic search', statusFilter: 'deviation', view: 'records', selectedRecordId: records[2].id });
expect(nav('Client Info').classList.contains('active')).toBe(true);
expect(container.querySelector('[data-testid="client-info"]')).not.toBeNull();
expect(!work() || Boolean(work()!.closest('[hidden]'))).toBe(true);
await act(async () => nav('Work').click());
expect(work()!.dataset.focused).toBeUndefined();
expect(work()!.querySelectorAll('.record-card')).toHaveLength(3);
expect(work()!.querySelectorAll('.related-finding-ring[aria-pressed="true"]')).toHaveLength(0);
} finally { await act(async () => root.unmount()); container.remove(); }
});

View File

@ -25,7 +25,8 @@ describe('Work header layout', () => {
it('oeffnet Work aus der Navigation mit Standardfilter und erhaelt explizite Drilldowns', () => { it('oeffnet Work aus der Navigation mit Standardfilter und erhaelt explizite Drilldowns', () => {
expect(app).toMatch(/const openWork = useCallback\(\(\) => \{\s*setView\('records'\);\s*setActiveArea\('work'\);/s); expect(app).toMatch(/const openWork = useCallback\(\(\) => \{\s*setView\('records'\);\s*setActiveArea\('work'\);/s);
expect(app).toContain("onClick={() => area === 'work' ? openWorkDefault() : setActiveArea(area)}"); expect(app).toContain('onClick={() => navigateArea(area)}');
expect(app).toContain("if (area === 'work') openWorkDefault(); else setActiveArea(area);");
expect(app).toContain('if (!hasIncomingFilter) app.resetFilters();'); expect(app).toContain('if (!hasIncomingFilter) app.resetFilters();');
expect(app).toContain('<DashboardPanel onOpenWork={openWork}/>'); expect(app).toContain('<DashboardPanel onOpenWork={openWork}/>');
expect(app).toContain('onOpenFinding={openWork}'); expect(app).toContain('onOpenFinding={openWork}');