Skip to content

Commit fe3d615

Browse files
authored
feat: add event chain panel component (#1494)
* feat: add event chain panel component for transitive closure visualization Displays hop-by-hop event chain results with vertical timeline, filter badge color coding (pass/fail/indeterminate), accordion expand/collapse, produced events list, and termination reason badges. * fix: remove unused import in event-chain-panel test * fix: move saga link outside accordion trigger for valid HTML Extracts the clickable saga name from inside AccordionTrigger to avoid nesting interactive elements. Renders a proper button when onSagaClick is provided, plain text otherwise. * fix: add aria-label to accordion trigger for screen readers --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 6a4a997 commit fe3d615

2 files changed

Lines changed: 455 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { render, screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { EventChainPanel } from './event-chain-panel'
5+
import type { EventChain, EventHop } from '../lib/transitive-closure'
6+
7+
function makeHop(overrides: Partial<EventHop> = {}): EventHop {
8+
return {
9+
depth: 1,
10+
trigger: {
11+
channel: 'position-keeping.transaction-captured.v1',
12+
instrumentCode: 'GBP',
13+
accountId: null,
14+
direction: null,
15+
},
16+
saga: 'process-payment',
17+
filterExpression: 'event.instrumentCode == "GBP"',
18+
filterResult: 'pass',
19+
filterReason: 'Instrument matches literal',
20+
producedEvents: [
21+
{
22+
channel: 'position-keeping.transaction-captured.v1',
23+
instrumentCode: 'USD',
24+
accountId: 'fees',
25+
direction: 'DEBIT',
26+
},
27+
],
28+
...overrides,
29+
}
30+
}
31+
32+
function makeChain(overrides: Partial<EventChain> = {}): EventChain {
33+
return {
34+
hops: [makeHop()],
35+
terminationReason: 'no_matching_sagas',
36+
maxDepthUsed: 1,
37+
...overrides,
38+
}
39+
}
40+
41+
describe('EventChainPanel', () => {
42+
it('renders multi-hop chain with depth indicators', () => {
43+
const chain = makeChain({
44+
hops: [
45+
makeHop({ depth: 1, saga: 'saga-a' }),
46+
makeHop({ depth: 2, saga: 'saga-b' }),
47+
],
48+
maxDepthUsed: 2,
49+
})
50+
51+
render(<EventChainPanel chain={chain} startNodeLabel="GBP Instrument" />)
52+
53+
expect(screen.getByText('Event chain from GBP Instrument')).toBeDefined()
54+
expect(screen.getByText('Depth: 2')).toBeDefined()
55+
expect(screen.getByText('Hop 1')).toBeDefined()
56+
expect(screen.getByText('Hop 2')).toBeDefined()
57+
})
58+
59+
it('renders accordion items that expand on click', async () => {
60+
const user = userEvent.setup()
61+
const chain = makeChain()
62+
63+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
64+
65+
// Accordion content should not be visible initially
66+
expect(screen.queryByText('Trigger:')).toBeNull()
67+
68+
// Click the accordion trigger to expand
69+
const accordionTrigger = document.querySelector('[data-slot="accordion-trigger"]')!
70+
await user.click(accordionTrigger)
71+
72+
expect(screen.getByText('Trigger:')).toBeDefined()
73+
expect(screen.getByText('Reason:')).toBeDefined()
74+
})
75+
76+
it('shows pass filter badge with green styling', () => {
77+
const chain = makeChain({
78+
hops: [makeHop({ filterResult: 'pass' })],
79+
})
80+
81+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
82+
83+
const badge = screen.getByTestId('filter-badge-pass')
84+
expect(badge.textContent).toBe('pass')
85+
expect(badge.className).toContain('bg-emerald-100')
86+
})
87+
88+
it('shows fail filter badge with red styling', () => {
89+
const chain = makeChain({
90+
hops: [makeHop({ filterResult: 'fail' })],
91+
})
92+
93+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
94+
95+
const badge = screen.getByTestId('filter-badge-fail')
96+
expect(badge.textContent).toBe('fail')
97+
expect(badge.className).toContain('bg-red-100')
98+
})
99+
100+
it('shows indeterminate filter badge with amber styling', () => {
101+
const chain = makeChain({
102+
hops: [makeHop({ filterResult: 'indeterminate' })],
103+
})
104+
105+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
106+
107+
const badge = screen.getByTestId('filter-badge-indeterminate')
108+
expect(badge.textContent).toBe('indeterminate')
109+
expect(badge.className).toContain('bg-amber-100')
110+
})
111+
112+
it('displays termination reason for filter_rejection', () => {
113+
const chain = makeChain({ terminationReason: 'filter_rejection' })
114+
115+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
116+
117+
const reason = screen.getByTestId('termination-reason')
118+
expect(reason.textContent).toContain('all sagas filtered out')
119+
})
120+
121+
it('displays termination reason for chain_depth_limit', () => {
122+
const chain = makeChain({ terminationReason: 'chain_depth_limit' })
123+
124+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
125+
126+
const reason = screen.getByTestId('termination-reason')
127+
expect(reason.textContent).toContain('maximum depth reached')
128+
})
129+
130+
it('displays termination reason for no_matching_sagas', () => {
131+
const chain = makeChain({ terminationReason: 'no_matching_sagas' })
132+
133+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
134+
135+
const reason = screen.getByTestId('termination-reason')
136+
expect(reason.textContent).toContain('no matching sagas found')
137+
})
138+
139+
it('fires onSagaClick when saga name is clicked', async () => {
140+
const user = userEvent.setup()
141+
const onSagaClick = vi.fn()
142+
const chain = makeChain({
143+
hops: [makeHop({ saga: 'my-saga' })],
144+
})
145+
146+
render(
147+
<EventChainPanel
148+
chain={chain}
149+
startNodeLabel="GBP"
150+
onSagaClick={onSagaClick}
151+
/>,
152+
)
153+
154+
await user.click(screen.getByTestId('saga-link-my-saga'))
155+
expect(onSagaClick).toHaveBeenCalledWith('my-saga')
156+
})
157+
158+
it('renders empty chain with message', () => {
159+
const chain = makeChain({ hops: [], maxDepthUsed: 0 })
160+
161+
render(<EventChainPanel chain={chain} startNodeLabel="kWh" />)
162+
163+
expect(screen.getByText('No event chain from kWh.')).toBeDefined()
164+
})
165+
166+
it('shows produced events in expanded accordion', async () => {
167+
const user = userEvent.setup()
168+
const chain = makeChain({
169+
hops: [
170+
makeHop({
171+
producedEvents: [
172+
{
173+
channel: 'position-keeping.transaction-captured.v1',
174+
instrumentCode: 'USD',
175+
accountId: null,
176+
direction: 'CREDIT',
177+
},
178+
],
179+
}),
180+
],
181+
})
182+
183+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
184+
185+
// Expand accordion
186+
const accordionTrigger = document.querySelector('[data-slot="accordion-trigger"]')!
187+
await user.click(accordionTrigger)
188+
189+
expect(screen.getByText('Produced events:')).toBeDefined()
190+
expect(screen.getByText(/\[USD\].*\(CREDIT\)/)).toBeDefined()
191+
})
192+
193+
it('shows saga diagram toggle button', async () => {
194+
const user = userEvent.setup()
195+
const chain = makeChain({
196+
hops: [makeHop({ saga: 'test-saga' })],
197+
})
198+
199+
render(<EventChainPanel chain={chain} startNodeLabel="GBP" />)
200+
201+
// Expand accordion first
202+
const accordionTrigger = document.querySelector('[data-slot="accordion-trigger"]')!
203+
await user.click(accordionTrigger)
204+
205+
const toggleBtn = screen.getByTestId('saga-diagram-toggle-test-saga')
206+
expect(toggleBtn.textContent).toBe('Show saga flow')
207+
208+
await user.click(toggleBtn)
209+
expect(screen.getByTestId('saga-diagram-test-saga')).toBeDefined()
210+
expect(toggleBtn.textContent).toBe('Hide saga flow')
211+
})
212+
})

0 commit comments

Comments
 (0)