diff --git a/docs/studio-guide.md b/docs/studio-guide.md index 582e81a..6a8b8a7 100644 --- a/docs/studio-guide.md +++ b/docs/studio-guide.md @@ -88,8 +88,9 @@ properties below the typed ones. Click **Analyze** to send the whole model (every page) to the live `/v1` engine. Findings come back and are **overlaid on the offending nodes and edges**, so you can see exactly what to fix. In a -multi-page model, tabs that carry findings are badged, and clicking a finding jumps to its page. Open -the inspector, set the missing property (e.g. a flow's protocol), and re-analyze. +multi-page model, tabs that carry findings are badged. Click a threat or finding to narrow the overlay +to only the objects it impacts and jump to their page. Open the inspector, set the missing property +(e.g. a flow's protocol), and re-analyze. If the engine is offline, Studio falls back to an offline stub so the canvas keeps working; connect it to a running API to get the real rule set. See [Analysis rules & CI](analysis-rules.md) for diff --git a/src/ThreatModelForge.Studio/src/dfd/Editor.test.ts b/src/ThreatModelForge.Studio/src/dfd/Editor.test.ts index 2913fc8..5ad2f12 100644 --- a/src/ThreatModelForge.Studio/src/dfd/Editor.test.ts +++ b/src/ThreatModelForge.Studio/src/dfd/Editor.test.ts @@ -275,6 +275,15 @@ describe('Editor — applyFlags', () => { expect(cleared.nodes.every((n) => n.className === undefined)).toBe(true); expect(cleared.edges.every((e) => e.className === undefined)).toBe(true); }); + + it('replaces aggregate flags with the elements referenced by one finding', () => { + const aggregate = applyFlags(nodes, edges, new Set(['n1', 'n2', 'e1'])); + const focused = applyFlags(aggregate.nodes, aggregate.edges, new Set(['n2'])); + + expect(focused.nodes[0].className).toBeUndefined(); + expect(focused.nodes[1].className).toBe('flagged'); + expect(focused.edges[0].className).toBeUndefined(); + }); }); describe('Editor — Tidy edge geometry', () => { diff --git a/src/ThreatModelForge.Studio/src/dfd/Editor.tsx b/src/ThreatModelForge.Studio/src/dfd/Editor.tsx index 796cdd6..84990dd 100644 --- a/src/ThreatModelForge.Studio/src/dfd/Editor.tsx +++ b/src/ThreatModelForge.Studio/src/dfd/Editor.tsx @@ -1084,18 +1084,24 @@ export function Editor() { setThreats((prev) => prev.filter((t) => t.id !== threat.id)); }, []); - // Navigates to the page holding the first of a threat's / finding's referenced elements. + // Narrows the canvas highlights to one threat / finding, then navigates to its first referenced page. const jumpToElements = useCallback( (elementIds: string[]) => { - for (const id of elementIds) { - const pageId = elementPageIndex.get(id); - if (pageId && pageId !== activePageId) { - switchPage(pageId); - return; - } + const focused = new Set(elementIds); + flaggedIdsRef.current = focused; + const pageId = elementIds + .map((id) => elementPageIndex.get(id)) + .find((id): id is string => Boolean(id)); + if (pageId && pageId !== activePageId) { + switchPage(pageId); + return; } + + const applied = applyFlags(nodes, edges, focused); + setNodes(applied.nodes); + setEdges(applied.edges); }, - [elementPageIndex, activePageId, switchPage], + [elementPageIndex, activePageId, switchPage, nodes, edges, setNodes, setEdges], ); // The name of the page a threat's elements live on, when that is not the page in view (for a badge). diff --git a/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.test.tsx b/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.test.tsx index e285a80..140671b 100644 --- a/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.test.tsx +++ b/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.test.tsx @@ -252,4 +252,22 @@ describe('ThreatsPanel', () => { expect(screen.getByText('Other findings')).toBeInTheDocument(); expect(screen.getByText('The model has too few components.')).toBeInTheDocument(); }); + + it("calls onSelect with the clicked finding's impacted element ids", () => { + const findings: Finding[] = [ + { + id: 'TM1010:flow-1', + severity: 'warning', + ruleId: 'TM1010', + message: 'The flow has no destination port.', + elementIds: ['flow-1', 'api'], + }, + ]; + const { onSelect } = renderPanel({ threats: [], findings }); + + fireEvent.click(screen.getByText('The flow has no destination port.')); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(['flow-1', 'api']); + }); }); diff --git a/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.tsx b/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.tsx index 1cfd9b3..9ce6d75 100644 --- a/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.tsx +++ b/src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.tsx @@ -273,7 +273,7 @@ export interface ThreatsPanelProps { threats: Threat[]; /** Non-threat hygiene findings (structural / naming rules), shown in a trailing section. */ findings: Finding[]; - /** Navigates to (and reveals) the elements a threat or finding refers to. */ + /** Narrows the canvas highlights to the elements a threat or finding refers to and reveals them. */ onSelect: (elementIds: string[]) => void; /** Returns the name of the page an item's elements live on, when it is not the active page. */ offPageLabel: (elementIds: string[]) => string | undefined;