Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/studio-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions src/ThreatModelForge.Studio/src/dfd/Editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 14 additions & 8 deletions src/ThreatModelForge.Studio/src/dfd/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
18 changes: 18 additions & 0 deletions src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
});
});
2 changes: 1 addition & 1 deletion src/ThreatModelForge.Studio/src/dfd/ThreatsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down