Skip to content

Commit 534cad1

Browse files
authored
feat: implement linked experience for cookbook pattern detail (#1450)
* feat: linked experience connecting editor, diagram, and handler reference Add LinkedPatternDetail component that connects the three views: - Click step in SagaFlowDiagram scrolls editor to corresponding line - Step click highlights the primary handler in HandlerReference panel - Handler reference displays service names extracted from saga flow - Add highlightedHandler prop to HandlerReference for visual emphasis Replace the Flow tab's separate diagram with the linked three-panel layout. * refactor: use editorViewRef prop instead of DOM internal access Address review feedback: - Add editorViewRef prop to StarlarkEditor for external EditorView access - Remove double dispatch in LinkedPatternDetail (was dispatching twice on step click) - Remove reliance on undocumented cmView internal from CodeMirror DOM * fix: add bounds check for line number and clear stale handler highlight - Guard against RangeError when lineNumber is out of document bounds - Clear highlightedHandler when clicked step has no service calls * test: add regression test for clearing stale handler highlight Verify that clicking a step with no service calls clears the previously highlighted handler in the reference panel. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent a47f295 commit 534cad1

6 files changed

Lines changed: 392 additions & 23 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { render, screen, fireEvent } from '@testing-library/react'
3+
import { LinkedPatternDetail } from './linked-detail'
4+
import type { SagaFlow } from '../lib/star-parser'
5+
6+
// Mock CodeMirror (jsdom doesn't support it)
7+
vi.mock('codemirror', () => ({ basicSetup: [] }))
8+
9+
const mockDispatch = vi.fn()
10+
let lastEditorDoc = ''
11+
12+
vi.mock('@codemirror/view', () => ({
13+
EditorView: class MockEditorView {
14+
static editable = { of: vi.fn(() => ({})) }
15+
static updateListener = { of: vi.fn(() => ({})) }
16+
static lineWrapping = {}
17+
dom: HTMLElement
18+
state: { doc: { toString: () => string; line: (n: number) => { from: number; to: number } } }
19+
dispatch = mockDispatch
20+
21+
constructor(config: { doc?: string; extensions?: unknown[]; parent?: HTMLElement }) {
22+
this.dom = document.createElement('div')
23+
this.dom.className = 'cm-editor'
24+
lastEditorDoc = config.doc ?? ''
25+
this.state = {
26+
doc: {
27+
toString: () => lastEditorDoc,
28+
line: (n: number) => ({ from: (n - 1) * 20, to: n * 20 }),
29+
},
30+
}
31+
if (config.parent) config.parent.appendChild(this.dom)
32+
}
33+
34+
destroy() {
35+
this.dom.remove()
36+
}
37+
},
38+
Decoration: {
39+
mark: vi.fn(() => ({
40+
range: vi.fn((_from: number, _to: number) => ({})),
41+
})),
42+
set: vi.fn(() => ({})),
43+
},
44+
ViewPlugin: {
45+
fromClass: vi.fn(() => ({})),
46+
},
47+
}))
48+
49+
vi.mock('@codemirror/state', () => ({
50+
Compartment: class {
51+
of = vi.fn(() => ({}))
52+
reconfigure = vi.fn(() => ({}))
53+
},
54+
EditorState: { create: vi.fn(() => ({})), readOnly: { of: vi.fn(() => ({})) } },
55+
Transaction: { userEvent: 'user-event' },
56+
RangeSetBuilder: class {
57+
add = vi.fn()
58+
finish = vi.fn(() => ({}))
59+
},
60+
StateField: { define: vi.fn(() => ({})) },
61+
StateEffect: { define: vi.fn(() => ({ of: vi.fn(() => ({})) })) },
62+
}))
63+
64+
vi.mock('@codemirror/lang-python', () => ({ python: vi.fn(() => ({})) }))
65+
vi.mock('@codemirror/lint', () => ({
66+
linter: vi.fn(() => ({})),
67+
lintGutter: vi.fn(() => ({})),
68+
}))
69+
70+
// Mock @xyflow/react
71+
vi.mock('@xyflow/react', () => ({
72+
ReactFlow: ({ onNodeClick, nodes }: { onNodeClick?: (e: unknown, node: unknown) => void; nodes: unknown[] }) => (
73+
<div data-testid="react-flow">
74+
{(nodes as { id: string; type: string; data: { label: string; serviceCalls?: { service: string; method: string }[] } }[]).map((node) => (
75+
<button
76+
key={node.id}
77+
data-testid={`flow-node-${node.id}`}
78+
data-node-type={node.type}
79+
onClick={(e) => onNodeClick?.(e, node)}
80+
>
81+
{node.data.label}
82+
{node.data.serviceCalls?.map((sc: { service: string; method: string }, i: number) => (
83+
<span key={i} data-testid={`service-call-${node.id}-${i}`}>
84+
{sc.service}.{sc.method}
85+
</span>
86+
))}
87+
</button>
88+
))}
89+
</div>
90+
),
91+
Controls: () => <div data-testid="flow-controls" />,
92+
Background: () => <div data-testid="flow-background" />,
93+
BackgroundVariant: { Dots: 'dots' },
94+
Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
95+
Handle: () => null,
96+
}))
97+
98+
// Mock @dagrejs/dagre
99+
vi.mock('@dagrejs/dagre', () => ({
100+
default: {
101+
graphlib: {
102+
Graph: class {
103+
setDefaultEdgeLabel = vi.fn().mockReturnThis()
104+
setGraph = vi.fn()
105+
setNode = vi.fn()
106+
setEdge = vi.fn()
107+
node = vi.fn(() => ({ x: 100, y: 100 }))
108+
},
109+
},
110+
layout: vi.fn(),
111+
},
112+
}))
113+
114+
// Mock handler reference
115+
vi.mock('@/shared/handler-reference', () => ({
116+
HandlerReference: ({ serviceNames, highlightedHandler }: { serviceNames?: string[]; highlightedHandler?: string }) => (
117+
<div data-testid="handler-reference" data-services={serviceNames?.join(',')} data-highlighted={highlightedHandler ?? ''}>
118+
Handler Reference
119+
</div>
120+
),
121+
}))
122+
123+
// Mock API context
124+
vi.mock('@/api/context', () => ({
125+
useApiClients: vi.fn(() => ({
126+
sagaRegistry: {
127+
describeHandlers: vi.fn(async () => ({ services: [] })),
128+
},
129+
})),
130+
}))
131+
132+
const sampleFlow: SagaFlow = {
133+
name: 'deposit-saga',
134+
trigger: 'payment.inbound',
135+
filter: null,
136+
steps: [
137+
{
138+
name: 'validate_payment',
139+
lineNumber: 5,
140+
serviceCalls: [
141+
{ service: 'position_keeping', method: 'initiate_log', params: ['amount'] },
142+
],
143+
earlyExit: null,
144+
},
145+
{
146+
name: 'apply_credit',
147+
lineNumber: 15,
148+
serviceCalls: [
149+
{ service: 'position_keeping', method: 'apply_credit', params: ['amount', 'direction'] },
150+
{ service: 'fees', method: 'calculate', params: ['amount'] },
151+
],
152+
earlyExit: null,
153+
},
154+
],
155+
}
156+
157+
const sampleStarlark = `# Saga: deposit-saga
158+
# Trigger: payment.inbound
159+
def execute(input_data):
160+
step(name="validate_payment")
161+
position_keeping.initiate_log(amount=input_data.amount)
162+
163+
step(name="apply_credit")
164+
position_keeping.apply_credit(amount=input_data.amount, direction="CREDIT")
165+
fees.calculate(amount=input_data.amount)
166+
`
167+
168+
describe('LinkedPatternDetail', () => {
169+
beforeEach(() => {
170+
vi.clearAllMocks()
171+
})
172+
173+
it('renders editor, diagram, and handler reference panels', () => {
174+
render(
175+
<LinkedPatternDetail
176+
flow={sampleFlow}
177+
starlarkContent={sampleStarlark}
178+
/>,
179+
)
180+
expect(screen.getByTestId('starlark-editor')).toBeInTheDocument()
181+
expect(screen.getByTestId('react-flow')).toBeInTheDocument()
182+
expect(screen.getByTestId('handler-reference')).toBeInTheDocument()
183+
})
184+
185+
it('renders three-panel layout with resizable areas', () => {
186+
const { container } = render(
187+
<LinkedPatternDetail
188+
flow={sampleFlow}
189+
starlarkContent={sampleStarlark}
190+
/>,
191+
)
192+
expect(container.querySelector('[data-testid="linked-detail"]')).toBeInTheDocument()
193+
})
194+
195+
it('highlights handler reference when step is clicked in diagram', () => {
196+
render(
197+
<LinkedPatternDetail
198+
flow={sampleFlow}
199+
starlarkContent={sampleStarlark}
200+
/>,
201+
)
202+
203+
const stepNode = screen.getByTestId('flow-node-step-0')
204+
fireEvent.click(stepNode)
205+
206+
const handlerRef = screen.getByTestId('handler-reference')
207+
expect(handlerRef.dataset.highlighted).toBe('position_keeping.initiate_log')
208+
})
209+
210+
it('updates selected step state when diagram step is clicked', () => {
211+
render(
212+
<LinkedPatternDetail
213+
flow={sampleFlow}
214+
starlarkContent={sampleStarlark}
215+
/>,
216+
)
217+
218+
const step1 = screen.getByTestId('flow-node-step-1')
219+
fireEvent.click(step1)
220+
221+
const handlerRef = screen.getByTestId('handler-reference')
222+
// The second step has position_keeping.apply_credit as first service call
223+
expect(handlerRef.dataset.highlighted).toBe('position_keeping.apply_credit')
224+
})
225+
226+
it('passes service names to handler reference from flow', () => {
227+
render(
228+
<LinkedPatternDetail
229+
flow={sampleFlow}
230+
starlarkContent={sampleStarlark}
231+
/>,
232+
)
233+
234+
const handlerRef = screen.getByTestId('handler-reference')
235+
const services = handlerRef.dataset.services?.split(',') ?? []
236+
expect(services).toContain('position_keeping')
237+
expect(services).toContain('fees')
238+
})
239+
240+
it('dispatches to editor when step is clicked to scroll to line', () => {
241+
render(
242+
<LinkedPatternDetail
243+
flow={sampleFlow}
244+
starlarkContent={sampleStarlark}
245+
/>,
246+
)
247+
248+
const stepNode = screen.getByTestId('flow-node-step-0')
249+
fireEvent.click(stepNode)
250+
251+
// Editor dispatch should be called to scroll/highlight
252+
expect(mockDispatch).toHaveBeenCalled()
253+
})
254+
255+
it('clears highlighted handler when clicked step has no service calls', () => {
256+
const flowWithNoop: SagaFlow = {
257+
...sampleFlow,
258+
steps: [
259+
...sampleFlow.steps,
260+
{ name: 'noop', lineNumber: 20, serviceCalls: [], earlyExit: null },
261+
],
262+
}
263+
264+
render(<LinkedPatternDetail flow={flowWithNoop} starlarkContent={sampleStarlark} />)
265+
266+
fireEvent.click(screen.getByTestId('flow-node-step-0'))
267+
expect(screen.getByTestId('handler-reference').dataset.highlighted).toBe('position_keeping.initiate_log')
268+
269+
fireEvent.click(screen.getByTestId('flow-node-step-2'))
270+
expect(screen.getByTestId('handler-reference').dataset.highlighted).toBe('')
271+
})
272+
273+
it('renders without crashing when flow has no steps', () => {
274+
const emptyFlow: SagaFlow = {
275+
name: 'empty-saga',
276+
trigger: null,
277+
filter: null,
278+
steps: [],
279+
}
280+
281+
render(
282+
<LinkedPatternDetail
283+
flow={emptyFlow}
284+
starlarkContent=""
285+
/>,
286+
)
287+
288+
expect(screen.getByTestId('linked-detail')).toBeInTheDocument()
289+
})
290+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useState, useMemo, useCallback, useRef } from 'react'
2+
import { type EditorView } from '@codemirror/view'
3+
import { StarlarkEditor } from '@/features/sagas/components/starlark-editor'
4+
import { HandlerReference } from '@/shared/handler-reference'
5+
import { SagaFlowDiagram } from './saga-flow'
6+
import type { SagaFlow } from '../lib/star-parser'
7+
8+
interface LinkedPatternDetailProps {
9+
flow: SagaFlow
10+
starlarkContent: string
11+
}
12+
13+
export function LinkedPatternDetail({ flow, starlarkContent }: LinkedPatternDetailProps) {
14+
const [highlightedHandler, setHighlightedHandler] = useState<string | null>(null)
15+
const editorViewRef = useRef<EditorView | null>(null)
16+
17+
const serviceNames = useMemo(() => {
18+
const names = new Set<string>()
19+
for (const step of flow.steps) {
20+
for (const call of step.serviceCalls) {
21+
names.add(call.service)
22+
}
23+
}
24+
return Array.from(names)
25+
}, [flow])
26+
27+
const handleStepClick = useCallback((stepName: string, lineNumber: number) => {
28+
const view = editorViewRef.current
29+
if (view && lineNumber >= 1 && lineNumber <= view.state.doc.lines) {
30+
const line = view.state.doc.line(lineNumber)
31+
view.dispatch({
32+
selection: { anchor: line.from },
33+
scrollIntoView: true,
34+
})
35+
}
36+
37+
const step = flow.steps.find((s) => s.name === stepName)
38+
if (step && step.serviceCalls.length > 0) {
39+
const firstCall = step.serviceCalls[0]
40+
setHighlightedHandler(`${firstCall.service}.${firstCall.method}`)
41+
} else {
42+
setHighlightedHandler(null)
43+
}
44+
}, [flow])
45+
46+
return (
47+
<div data-testid="linked-detail" className="flex flex-col gap-4 lg:flex-row lg:gap-6">
48+
{/* Left panel: Starlark editor */}
49+
<div className="flex-1 min-w-0">
50+
<StarlarkEditor
51+
value={starlarkContent}
52+
onChange={() => {}}
53+
readOnly
54+
editorViewRef={editorViewRef}
55+
/>
56+
</div>
57+
58+
{/* Right panel: Diagram + Handler reference */}
59+
<div className="flex flex-col gap-4 lg:w-[480px] lg:shrink-0">
60+
<div className="h-[400px] rounded-lg border">
61+
<SagaFlowDiagram
62+
flow={flow}
63+
onStepClick={handleStepClick}
64+
/>
65+
</div>
66+
67+
<div className="rounded-lg border p-3">
68+
<h3 className="mb-2 text-sm font-medium text-muted-foreground">Handler Reference</h3>
69+
<HandlerReference
70+
serviceNames={serviceNames.length > 0 ? serviceNames : undefined}
71+
highlightedHandler={highlightedHandler ?? undefined}
72+
/>
73+
</div>
74+
</div>
75+
</div>
76+
)
77+
}

frontend/src/features/cookbook/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { usePatternFiles } from './hooks/use-pattern-files'
88
export { ManifestViewer } from './components/manifest-viewer'
99
export { ComponentDetail } from './components/component-detail'
1010
export { SagaFlowDiagram } from './components/saga-flow'
11+
export { LinkedPatternDetail } from './components/linked-detail'
1112
export { PreviewSourceTabs } from './components/preview-source-tabs'
1213
export { parseStarlarkSaga } from './lib/star-parser'
1314
export type { SagaFlow, SagaFlowStep, ServiceCall, EarlyExit } from './lib/star-parser'

0 commit comments

Comments
 (0)