Skip to content

Commit 6ae11a6

Browse files
authored
feat: add event chain button and side panel to manifest graph (#1495)
* feat: add event chain button and side panel to manifest graph - Add node selection on click (single click selects, double click navigates) - Show toolbar with "Show Event Chain" button for instrument/account_type nodes - Create useEventChain hook wrapping computeTransitiveClosure - Display EventChainPanel in slide-out side panel - Update existing tests for new click behavior, add selection and event chain tests * fix: clear node selection when type is filtered out Address review feedback: when a selected node's type gets hidden via the filter sidebar, clear the selection and close the event chain panel. * fix: strip saga: prefix from event chain saga navigation The onSagaClick callback receives the full node ID (saga:name) from transitive-closure. Strip the prefix before navigating to /sagas/<name>. * fix: clear stale selection on graph change and panel close - Derive effectiveSelectedNode that resets to null when the selected node no longer exists in the graph (handles manifest prop changes) - Close event chain panel button now also clears node selection --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent fe3d615 commit 6ae11a6

4 files changed

Lines changed: 276 additions & 18 deletions

File tree

frontend/src/features/manifests/components/manifest-graph.test.tsx

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,19 @@ vi.mock('@xyflow/react', () => {
2828

2929
function Handle() { return null }
3030

31-
function ReactFlow({ nodes, edges, onNodeClick, children }: {
31+
function ReactFlow({ nodes, edges, onNodeClick, onNodeDoubleClick, onPaneClick, children }: {
3232
nodes: { id: string; type?: string; data: Record<string, unknown> }[]
3333
edges: { id: string; source: string; target: string; data?: Record<string, unknown> }[]
3434
onNodeClick?: (event: unknown, node: unknown) => void
35+
onNodeDoubleClick?: (event: unknown, node: unknown) => void
36+
onPaneClick?: () => void
3537
children?: React.ReactNode
3638
[key: string]: unknown
3739
}) {
3840
return (
39-
<div data-testid="react-flow" data-node-count={nodes.length} data-edge-count={edges.length}>
41+
<div data-testid="react-flow" data-node-count={nodes.length} data-edge-count={edges.length} onClick={(e) => {
42+
if ((e.target as HTMLElement).getAttribute('data-testid') === 'react-flow') onPaneClick?.()
43+
}}>
4044
{nodes.map((n) => {
4145
const mn = (n.data as { manifestNode?: { type: string; label: string; data: Record<string, unknown> } }).manifestNode
4246
return (
@@ -46,7 +50,8 @@ vi.mock('@xyflow/react', () => {
4650
data-node-type={n.type}
4751
data-dimmed={String((n.data as { dimmed?: boolean }).dimmed ?? false)}
4852
data-highlighted={String((n.data as { highlighted?: boolean }).highlighted ?? false)}
49-
onClick={() => onNodeClick?.({}, n)}
53+
onClick={(e) => { e.stopPropagation(); onNodeClick?.({}, n) }}
54+
onDoubleClick={() => onNodeDoubleClick?.({}, n)}
5055
>
5156
{mn?.label ?? n.id}
5257
</div>
@@ -257,26 +262,102 @@ describe('ManifestGraph', () => {
257262
})
258263
})
259264

260-
describe('click navigation', () => {
261-
it('navigates to instruments page on instrument click', async () => {
265+
describe('double-click navigation', () => {
266+
it('navigates to instruments page on instrument double-click', async () => {
262267
renderGraph(energyManifest)
263268
const node = await screen.findByTestId('node-instrument:KWH')
264-
fireEvent.click(node)
269+
fireEvent.doubleClick(node)
265270
expect(mockNavigate).toHaveBeenCalledWith('/reference-data/instruments')
266271
})
267272

268-
it('navigates to account types page on account type click', async () => {
273+
it('navigates to account types page on account type double-click', async () => {
269274
renderGraph(energyManifest)
270275
const node = await screen.findByTestId('node-account_type:ENERGY_HOLDING')
271-
fireEvent.click(node)
276+
fireEvent.doubleClick(node)
272277
expect(mockNavigate).toHaveBeenCalledWith('/reference-data/account-types')
273278
})
274279

275-
it('navigates to saga detail page on saga click', async () => {
280+
it('navigates to saga detail page on saga double-click', async () => {
276281
renderGraph(energyManifest)
277282
const node = await screen.findByTestId('node-saga:usage_to_value')
278-
fireEvent.click(node)
283+
fireEvent.doubleClick(node)
279284
expect(mockNavigate).toHaveBeenCalledWith('/sagas/usage_to_value')
280285
})
281286
})
287+
288+
describe('node selection', () => {
289+
it('shows toolbar when an instrument node is clicked', async () => {
290+
renderGraph(energyManifest)
291+
const node = await screen.findByTestId('node-instrument:KWH')
292+
fireEvent.click(node)
293+
const toolbar = await screen.findByTestId('node-toolbar')
294+
expect(toolbar).toBeInTheDocument()
295+
expect(toolbar.textContent).toContain('Kilowatt Hour')
296+
})
297+
298+
it('shows "Show Event Chain" button for instrument nodes', async () => {
299+
renderGraph(energyManifest)
300+
const node = await screen.findByTestId('node-instrument:KWH')
301+
fireEvent.click(node)
302+
expect(await screen.findByTestId('show-event-chain-button')).toBeInTheDocument()
303+
})
304+
305+
it('shows "Show Event Chain" button for account_type nodes', async () => {
306+
renderGraph(energyManifest)
307+
const node = await screen.findByTestId('node-account_type:ENERGY_HOLDING')
308+
fireEvent.click(node)
309+
expect(await screen.findByTestId('show-event-chain-button')).toBeInTheDocument()
310+
})
311+
312+
it('does not show "Show Event Chain" button for saga nodes', async () => {
313+
renderGraph(energyManifest)
314+
const node = await screen.findByTestId('node-saga:usage_to_value')
315+
fireEvent.click(node)
316+
expect(await screen.findByTestId('node-toolbar')).toBeInTheDocument()
317+
expect(screen.queryByTestId('show-event-chain-button')).not.toBeInTheDocument()
318+
})
319+
320+
it('deselects node when clicking the same node again', async () => {
321+
renderGraph(energyManifest)
322+
const node = await screen.findByTestId('node-instrument:KWH')
323+
fireEvent.click(node)
324+
expect(await screen.findByTestId('node-toolbar')).toBeInTheDocument()
325+
fireEvent.click(node)
326+
expect(screen.queryByTestId('node-toolbar')).not.toBeInTheDocument()
327+
})
328+
329+
it('deselects node when clicking the pane', async () => {
330+
renderGraph(energyManifest)
331+
const node = await screen.findByTestId('node-instrument:KWH')
332+
fireEvent.click(node)
333+
expect(await screen.findByTestId('node-toolbar')).toBeInTheDocument()
334+
const pane = screen.getByTestId('react-flow')
335+
fireEvent.click(pane)
336+
expect(screen.queryByTestId('node-toolbar')).not.toBeInTheDocument()
337+
})
338+
})
339+
340+
describe('event chain panel', () => {
341+
it('opens event chain panel when "Show Event Chain" is clicked', async () => {
342+
renderGraph(energyManifest)
343+
const node = await screen.findByTestId('node-instrument:KWH')
344+
fireEvent.click(node)
345+
const button = await screen.findByTestId('show-event-chain-button')
346+
fireEvent.click(button)
347+
expect(await screen.findByTestId('event-chain-side-panel')).toBeInTheDocument()
348+
expect(screen.getByTestId('event-chain-panel')).toBeInTheDocument()
349+
})
350+
351+
it('closes event chain panel when close button is clicked', async () => {
352+
renderGraph(energyManifest)
353+
const node = await screen.findByTestId('node-instrument:KWH')
354+
fireEvent.click(node)
355+
const button = await screen.findByTestId('show-event-chain-button')
356+
fireEvent.click(button)
357+
expect(await screen.findByTestId('event-chain-side-panel')).toBeInTheDocument()
358+
const closeButton = screen.getByTestId('close-event-chain-panel')
359+
fireEvent.click(closeButton)
360+
expect(screen.queryByTestId('event-chain-side-panel')).not.toBeInTheDocument()
361+
})
362+
})
282363
})

frontend/src/features/manifests/components/manifest-graph.tsx

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515
} from '@xyflow/react'
1616
import '@xyflow/react/dist/style.css'
1717
import { useNavigate } from 'react-router-dom'
18+
import { X } from 'lucide-react'
1819
import {
1920
Tooltip,
2021
TooltipContent,
2122
TooltipProvider,
2223
TooltipTrigger,
2324
} from '@/components/ui/tooltip'
25+
import { Button } from '@/components/ui/button'
2426
import {
2527
layoutWithELK,
2628
NODE_WIDTH,
@@ -35,6 +37,8 @@ import {
3537
type ManifestGraph as ManifestGraphModel,
3638
} from '../lib/manifest-graph-model'
3739
import type { Manifest } from '@/api/gen/meridian/control_plane/v1/manifest_pb'
40+
import { useEventChain } from '../hooks/use-event-chain'
41+
import { EventChainPanel } from './event-chain-panel'
3842

3943
// Theme colors per node type
4044
const NODE_THEMES: Record<ManifestNodeType, { color: string; label: string }> = {
@@ -316,12 +320,30 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
316320
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
317321
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
318322
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
323+
const [selectedNode, setSelectedNode] = useState<string | null>(null)
324+
const [showEventChain, setShowEventChain] = useState(false)
319325
const [visibleTypes, setVisibleTypes] = useState<Set<ManifestNodeType>>(
320326
() => new Set<ManifestNodeType>(['instrument', 'account_type', 'valuation_rule', 'saga']),
321327
)
322328

323329
const graph = useMemo(() => buildManifestGraph(manifest), [manifest])
324330

331+
// Derive effective selection: clear if the selected node no longer exists in the graph
332+
const effectiveSelectedNode = useMemo(
333+
() => (selectedNode && graph.nodes.some((n) => n.id === selectedNode) ? selectedNode : null),
334+
[selectedNode, graph],
335+
)
336+
337+
const selectedManifestNode = useMemo(
338+
() => (effectiveSelectedNode ? graph.nodes.find((n) => n.id === effectiveSelectedNode) ?? null : null),
339+
[graph, effectiveSelectedNode],
340+
)
341+
342+
const canShowEventChain = selectedManifestNode?.type === 'instrument' || selectedManifestNode?.type === 'account_type'
343+
344+
const eventChainNodeId = showEventChain ? effectiveSelectedNode : null
345+
const eventChain = useEventChain(graph, eventChainNodeId)
346+
325347
const nodeCountByType = useMemo(() => {
326348
const counts: Record<ManifestNodeType, number> = {
327349
instrument: 0,
@@ -363,13 +385,14 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
363385
return () => { cancelled = true }
364386
}, [graph, visibleTypes, setNodes, setEdges])
365387

366-
// Hover highlighting
388+
// Hover + selection highlighting
367389
useEffect(() => {
390+
const activeNode = hoveredNode ?? effectiveSelectedNode
368391
const connectedNodes = new Set<string>()
369-
if (hoveredNode) {
370-
connectedNodes.add(hoveredNode)
392+
if (activeNode) {
393+
connectedNodes.add(activeNode)
371394
for (const e of currentEdges) {
372-
if (e.source === hoveredNode || e.target === hoveredNode) {
395+
if (e.source === activeNode || e.target === activeNode) {
373396
connectedNodes.add(e.source)
374397
connectedNodes.add(e.target)
375398
}
@@ -379,8 +402,8 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
379402
setNodes((nds) => {
380403
let changed = false
381404
const next = nds.map((n) => {
382-
const highlighted = hoveredNode ? n.id === hoveredNode : false
383-
const dimmed = hoveredNode ? !connectedNodes.has(n.id) : false
405+
const highlighted = activeNode ? n.id === activeNode : false
406+
const dimmed = activeNode ? !connectedNodes.has(n.id) : false
384407
const current = n.data as ManifestNodeData
385408
if (current.highlighted === highlighted && current.dimmed === dimmed) return n
386409
changed = true
@@ -399,9 +422,17 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
399422
})
400423
return changed ? next : eds
401424
})
402-
}, [hoveredNode, currentEdges, setNodes, setEdges])
425+
}, [hoveredNode, effectiveSelectedNode, currentEdges, setNodes, setEdges])
403426

404427
const onNodeClick: NodeMouseHandler = useCallback(
428+
(_event, node) => {
429+
setSelectedNode((prev) => (prev === node.id ? null : node.id))
430+
setShowEventChain(false)
431+
},
432+
[],
433+
)
434+
435+
const onNodeDoubleClick: NodeMouseHandler = useCallback(
405436
(_event, node) => {
406437
const data = node.data as ManifestNodeData
407438
const mn = data.manifestNode
@@ -420,6 +451,11 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
420451
[navigate],
421452
)
422453

454+
const onPaneClick = useCallback(() => {
455+
setSelectedNode(null)
456+
setShowEventChain(false)
457+
}, [])
458+
423459
const onNodeMouseEnter: NodeMouseHandler = useCallback((_event, node) => {
424460
setHoveredNode(node.id)
425461
}, [])
@@ -438,7 +474,12 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
438474
}
439475
return next
440476
})
441-
}, [])
477+
// Clear selection if the selected node's type was just hidden
478+
if (selectedManifestNode?.type === type) {
479+
setSelectedNode(null)
480+
setShowEventChain(false)
481+
}
482+
}, [selectedManifestNode])
442483

443484
const totalVisible = nodes.length
444485

@@ -461,6 +502,8 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
461502
onNodesChange={onNodesChange}
462503
onEdgesChange={onEdgesChange}
463504
onNodeClick={onNodeClick}
505+
onNodeDoubleClick={onNodeDoubleClick}
506+
onPaneClick={onPaneClick}
464507
onNodeMouseEnter={onNodeMouseEnter}
465508
onNodeMouseLeave={onNodeMouseLeave}
466509
nodeTypes={nodeTypes}
@@ -510,6 +553,67 @@ export function ManifestGraph({ manifest, className }: ManifestGraphProps) {
510553
<LegendItem key={item.label} {...item} />
511554
))}
512555
</div>
556+
557+
{/* Selection toolbar */}
558+
{selectedManifestNode && (
559+
<div
560+
className="absolute top-3 right-3 z-10 flex items-center gap-2 rounded-lg border bg-background/95 p-2 backdrop-blur-sm shadow-sm"
561+
data-testid="node-toolbar"
562+
>
563+
<span className="text-xs font-medium text-foreground px-1">
564+
{selectedManifestNode.label}
565+
</span>
566+
{canShowEventChain && (
567+
<Button
568+
size="sm"
569+
variant="outline"
570+
className="text-xs h-7"
571+
onClick={() => setShowEventChain(true)}
572+
data-testid="show-event-chain-button"
573+
>
574+
Show Event Chain
575+
</Button>
576+
)}
577+
<Button
578+
size="sm"
579+
variant="ghost"
580+
className="h-7 w-7 p-0"
581+
onClick={() => { setSelectedNode(null); setShowEventChain(false) }}
582+
aria-label="Deselect node"
583+
>
584+
<X className="h-3.5 w-3.5" />
585+
</Button>
586+
</div>
587+
)}
588+
589+
{/* Event chain side panel */}
590+
{showEventChain && eventChain && selectedManifestNode && (
591+
<div
592+
className="absolute top-0 right-0 z-20 h-full w-96 border-l bg-background shadow-lg overflow-y-auto"
593+
data-testid="event-chain-side-panel"
594+
>
595+
<div className="flex items-center justify-between p-3 border-b">
596+
<h3 className="text-sm font-semibold">Event Chain</h3>
597+
<Button
598+
size="sm"
599+
variant="ghost"
600+
className="h-7 w-7 p-0"
601+
onClick={() => { setSelectedNode(null); setShowEventChain(false) }}
602+
aria-label="Close event chain panel"
603+
data-testid="close-event-chain-panel"
604+
>
605+
<X className="h-3.5 w-3.5" />
606+
</Button>
607+
</div>
608+
<div className="p-3">
609+
<EventChainPanel
610+
chain={eventChain}
611+
startNodeLabel={selectedManifestNode.label}
612+
onSagaClick={(sagaId) => navigate(`/sagas/${sagaId.replace(/^saga:/, '')}`)}
613+
/>
614+
</div>
615+
</div>
616+
)}
513617
</div>
514618
)
515619
}

0 commit comments

Comments
 (0)