-
Notifications
You must be signed in to change notification settings - Fork 0
feat(frontend): saga timeline + narrator on order detail (epic #130 Phase 3) #167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
7d72760
feat(frontend): saga timeline + narrator on order detail (epic #130 P…
emeraldleaf 69f5d62
Merge remote-tracking branch 'origin/main' into feat/saga-narrator
emeraldleaf a11cd6b
Merge remote-tracking branch 'origin/main' into feat/saga-narrator
emeraldleaf 4eb6c62
fix(frontend): Cancelled renders honestly — no failure glyph on a ste…
emeraldleaf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
78 changes: 78 additions & 0 deletions
78
frontend/src/features/orders/components/SagaTimeline.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| import { render, screen } from '@testing-library/react' | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { SagaTimeline } from './SagaTimeline' | ||
|
|
||
| // Pure-render component (polling lives in the query, derivation in saga.ts), so these | ||
| // tests assert the user-visible contract per status — RTL by role/label, no internals. | ||
| describe('SagaTimeline', () => { | ||
| it('marks the live step and explains the async 202 while the order is Placed', () => { | ||
| // ARRANGE/ACT — freshly placed order: the saga has NOT run yet; this is the state a | ||
| // buyer lands on right after checkout redirects here. | ||
| render(<SagaTimeline status="Placed" />) | ||
|
|
||
| // ASSERT — | ||
| // 1. The Placed step carries aria-current="step": screen readers and tests agree on | ||
| // which step is live (the pulsing dot alone would be color-only signaling). | ||
| const steps = screen.getAllByRole('listitem') | ||
| expect(steps[0]).toHaveAttribute('aria-current', 'step') | ||
| // 2. The narrator explains the load-bearing backend fact — 202 + outbox — because | ||
| // "why is my order not confirmed instantly" is exactly what the panel exists to teach. | ||
| expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/202 Accepted/) | ||
| expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/transactional outbox/) | ||
| // 3. The polling hint is shown — the saga is still moving. | ||
| expect(screen.getByText(/polls the order every 2/)).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('narrates the payment hop when the order reaches Paid', () => { | ||
| // ARRANGE/ACT — mid-saga: PaymentService has consumed and re-published. | ||
| render(<SagaTimeline status="Paid" />) | ||
|
|
||
| // ASSERT — the narration advances with the saga: now it teaches the consume→publish | ||
| // hop and idempotency (at-least-once delivery), not the 202 story. | ||
| const panel = screen.getByLabelText('What the backend just did') | ||
| expect(panel).toHaveTextContent(/PaymentService consumed OrderPlacedEvent/) | ||
| expect(panel).toHaveTextContent(/idempotent/) | ||
| }) | ||
|
|
||
| it('shows a fully-complete timeline with no polling hint once Shipped', () => { | ||
| // ARRANGE/ACT — the saga settled on the happy path. | ||
| render(<SagaTimeline status="Shipped" />) | ||
|
|
||
| // ASSERT — | ||
| // 1. No step is "current" anymore — nothing is in flight. | ||
| for (const step of screen.getAllByRole('listitem')) { | ||
| expect(step).not.toHaveAttribute('aria-current') | ||
| } | ||
| // 2. The final narration teaches the punchline: choreography, no orchestrator. | ||
| expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/choreography saga/) | ||
| // 3. Polling hint gone — isSagaSettled stopped the refetch loop, the UI says so implicitly. | ||
| expect(screen.queryByText(/polls the order every 2/)).not.toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('surfaces the failure branch when payment is declined', () => { | ||
| // ARRANGE/ACT — the saga's compensating path: PaymentFailedEvent came back instead. | ||
| render(<SagaTimeline status="PaymentFailed" />) | ||
|
|
||
| // ASSERT — the failure is loud and explained, not a silent stuck timeline: | ||
| // the panel switches to the terminal narration naming the failure event. | ||
| expect(screen.getByText(/Saga stopped: PaymentFailed/)).toBeInTheDocument() | ||
| expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/PaymentFailedEvent/) | ||
| }) | ||
|
|
||
| it('surfaces the cancel branch without blaming the payment step', () => { | ||
| // ARRANGE/ACT — the other terminal: the buyer (or a compensation) cancelled the order. | ||
| render(<SagaTimeline status="Cancelled" />) | ||
|
|
||
| // ASSERT — | ||
| // 1. The terminal panel is loud and names the state, same contract as PaymentFailed. | ||
| expect(screen.getByText(/Saga stopped: Cancelled/)).toBeInTheDocument() | ||
| expect(screen.getByLabelText('What the backend just did')).toHaveTextContent(/cancelled/i) | ||
| // 2. No step renders the ✕ failure glyph: Cancel() is legal from Placed AND Paid, and | ||
| // the status alone can't say whether payment happened — a red Paid step would | ||
| // misstate a cancel-before-payment. (The failure glyph is PaymentFailed's contract.) | ||
| expect(screen.queryByText('✕')).not.toBeInTheDocument() | ||
| // 3. Settled: no polling hint — nothing more will happen to this order. | ||
| expect(screen.queryByText(/polls the order every 2/)).not.toBeInTheDocument() | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,86 @@ | ||
| import { SAGA_STEPS, TERMINAL_NARRATION, deriveStepStates, isSagaSettled, type OrderStatus, type StepState } from '../saga' | ||
|
|
||
| /** | ||
| * The saga narrator (epic #130 Phase 3): a Placed → Paid → Shipped timeline that renders | ||
| * live off the polled order status, plus a panel explaining what the backend just did to | ||
| * reach the current step. The demo IS the engineering — this component turns the | ||
| * choreography saga (outbox → RabbitMQ fanout → idempotent consumers) into something you | ||
| * can watch happen. | ||
| * | ||
| * Pure render off the status prop: polling cadence lives in orderByIdQuery, narration | ||
| * copy + step derivation live in ../saga.ts. This file only draws. | ||
| */ | ||
|
|
||
| const DOT_CLASSES: Record<StepState, string> = { | ||
| complete: 'bg-emerald-600 text-white', | ||
| active: 'animate-pulse bg-blue-600 text-white', | ||
| failed: 'bg-red-600 text-white', | ||
| pending: 'bg-zinc-200 text-zinc-500', | ||
| } | ||
|
|
||
| function dotGlyph(state: StepState, index: number): string { | ||
| if (state === 'complete') return '✓' | ||
| if (state === 'failed') return '✕' | ||
| return String(index + 1) | ||
| } | ||
|
|
||
| export function SagaTimeline({ status }: Readonly<{ status: OrderStatus }>) { | ||
| const stepStates = deriveStepStates(status) | ||
| const settled = isSagaSettled(status) | ||
| // Narrate the terminal branch if the saga died, otherwise the furthest step it reached. | ||
| const terminalNarration = TERMINAL_NARRATION[status] | ||
| let narratedIndex = -1 | ||
| for (let index = stepStates.length - 1; index >= 0; index--) { | ||
| if (stepStates[index] !== 'pending') { | ||
| narratedIndex = index | ||
| break | ||
| } | ||
| } | ||
| const narration = terminalNarration ?? SAGA_STEPS[narratedIndex]?.narration | ||
| const panelClasses = | ||
| terminalNarration == null ? 'border-zinc-200 bg-zinc-50 text-zinc-700' : 'border-red-200 bg-red-50 text-red-900' | ||
| const panelHeading = terminalNarration == null ? 'What the backend just did' : `Saga stopped: ${status}` | ||
|
|
||
| return ( | ||
| <section aria-label="Order saga progress" className="space-y-3"> | ||
| <ol className="flex items-center gap-0"> | ||
| {SAGA_STEPS.map((step, index) => { | ||
| // deriveStepStates maps 1:1 over SAGA_STEPS; the fallback only satisfies | ||
| // noUncheckedIndexedAccess (the index can't actually miss). | ||
| const state = stepStates[index] ?? 'pending' | ||
| return ( | ||
| <li key={step.status} className="flex flex-1 items-center" aria-current={state === 'active' ? 'step' : undefined}> | ||
| <span | ||
| className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${DOT_CLASSES[state]}`} | ||
| > | ||
| {dotGlyph(state, index)} | ||
| </span> | ||
| <span className="ml-2 text-sm font-medium"> | ||
| {step.title} | ||
| {/* Live step gets a visually-quiet hint that the page is polling. */} | ||
| {state === 'active' && <span className="ml-1 text-xs font-normal text-zinc-500">(in progress…)</span>} | ||
| </span> | ||
| {index < SAGA_STEPS.length - 1 && ( | ||
| <span aria-hidden className={`mx-3 h-px flex-1 ${stepStates[index + 1] === 'pending' ? 'bg-zinc-200' : 'bg-emerald-600'}`} /> | ||
| )} | ||
| </li> | ||
| ) | ||
| })} | ||
| </ol> | ||
|
|
||
| {status === 'Delivered' && <p className="text-sm text-zinc-600">Delivered — the saga completed end-to-end.</p>} | ||
|
|
||
| {narration != null && ( | ||
| <aside aria-label="What the backend just did" className={`rounded-md border p-3 text-sm leading-relaxed ${panelClasses}`}> | ||
| <p className="mb-1 font-semibold">{panelHeading}</p> | ||
| <p>{narration}</p> | ||
| {settled ? null : ( | ||
| <p className="mt-2 text-xs text-zinc-500"> | ||
| This page polls the order every 2 s — the status you're watching advances as each service consumes and re-publishes events. | ||
| </p> | ||
| )} | ||
| </aside> | ||
| )} | ||
| </section> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
|
|
||
| import { deriveStepStates, isSagaSettled, type OrderStatus } from './saga' | ||
|
|
||
| describe('isSagaSettled', () => { | ||
| it('keeps polling while the saga is in flight and stops once it settles', () => { | ||
| // ARRANGE/ACT/ASSERT — table test: this predicate IS the polling lifecycle | ||
| // (orderByIdQuery's refetchInterval returns false exactly when this returns true), | ||
| // so a wrong entry here means either polling forever (battery/network burn) or | ||
| // a timeline frozen at Placed while the backend moves on. | ||
| expect(isSagaSettled('Placed')).toBe(false) // saga just started — keep watching | ||
| expect(isSagaSettled('Paid')).toBe(false) // mid-saga — shipping still to come | ||
| expect(isSagaSettled('Shipped')).toBe(true) // demo happy-path terminal | ||
| expect(isSagaSettled('Delivered')).toBe(true) // post-shipping terminal | ||
| expect(isSagaSettled('Cancelled')).toBe(true) // cancel branch — nothing more will happen | ||
| expect(isSagaSettled('PaymentFailed')).toBe(true) // failure branch — saga stopped | ||
| }) | ||
| }) | ||
|
|
||
| describe('deriveStepStates', () => { | ||
| // Timeline is [Placed, Paid, Shipped]; each case pins the full visual contract for a status. | ||
| const cases: [OrderStatus, ReturnType<typeof deriveStepStates>][] = [ | ||
| // In-flight: the reached step pulses "active" (page is polling), the rest are pending. | ||
| ['Placed', ['active', 'pending', 'pending']], | ||
| ['Paid', ['complete', 'active', 'pending']], | ||
| // Settled happy path: everything reached is complete — no "active" pulse once polling stops. | ||
| ['Shipped', ['complete', 'complete', 'complete']], | ||
| ['Delivered', ['complete', 'complete', 'complete']], | ||
| // Failure branch: Placed succeeded, the payment step is where the saga died. | ||
| ['PaymentFailed', ['complete', 'failed', 'pending']], | ||
| // Cancelled: terminal but NOT a step failure. The DTO carries only the current status | ||
| // and the backend allows Cancel() from both Placed and Paid, so whether payment ever | ||
| // happened is unknowable client-side — marking Paid as failed would misstate a | ||
| // cancel-before-payment. Remaining steps stay pending; the terminal panel tells the story. | ||
| ['Cancelled', ['complete', 'pending', 'pending']], | ||
| ] | ||
|
|
||
| it.each(cases)('%s renders as %j', (status, expected) => { | ||
| // ACT — derive the per-step visual states from the polled status. | ||
| // ASSERT — the whole array at once: a step silently flipping between | ||
| // active/complete is exactly the regression class this guards. | ||
| expect(deriveStepStates(status)).toEqual(expected) | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| // The saga-narrator model: what the timeline shows and what each step teaches. | ||
| // | ||
| // This feature IS the documentation (frontend/CLAUDE.md "Conventions") — the narration | ||
| // strings explain, in the UI, what the backend just did. They mirror the real mechanics | ||
| // in OrderService/PaymentService/ShippingService: transactional outbox, RabbitMQ fanout | ||
| // exchanges, idempotent consumers, choreography (no orchestrator). If the backend's saga | ||
| // changes shape, this copy changes with it — it's a projection of the architecture, not | ||
| // marketing text. | ||
|
|
||
| /** Mirror of OrderService.Domain.OrderStatus — persisted (and serialized) as strings. */ | ||
| export type OrderStatus = 'Placed' | 'Paid' | 'Shipped' | 'Delivered' | 'Cancelled' | 'PaymentFailed' | ||
|
|
||
| export type StepState = 'complete' | 'active' | 'pending' | 'failed' | ||
|
|
||
| export interface SagaStep { | ||
| status: OrderStatus | ||
| title: string | ||
| /** What the backend just did to reach this step — shown in the narrator panel. */ | ||
| narration: string | ||
| } | ||
|
|
||
| /** The happy-path choreography, in order. Failure/cancel states overlay onto these. */ | ||
| export const SAGA_STEPS: readonly SagaStep[] = [ | ||
| { | ||
| status: 'Placed', | ||
| title: 'Placed', | ||
| narration: | ||
| 'POST /orders returned 202 Accepted, not 201 — placement is asynchronous and the order row itself is the tracking record. ' + | ||
| 'OrderService saved the order and staged OrderPlacedEvent in the SAME database transaction (transactional outbox — the event cannot be lost, even if the broker is down), ' + | ||
| 'then Wolverine published it to the order-events fanout exchange on RabbitMQ. This page is polling while the saga runs.', | ||
| }, | ||
| { | ||
| status: 'Paid', | ||
| title: 'Paid', | ||
| narration: | ||
| 'PaymentService consumed OrderPlacedEvent from its payment-orders queue, processed the payment, and published PaymentCompletedEvent to the payment-events exchange. ' + | ||
| 'OrderService consumed that and marked the order Paid. Delivery is at-least-once, so the handler is idempotent — a duplicate event hits the status guard and is ignored.', | ||
| }, | ||
| { | ||
| status: 'Shipped', | ||
| title: 'Shipped', | ||
| narration: | ||
| 'ShippingService reacted to the same PaymentCompletedEvent (its own queue on the fanout exchange), dispatched a shipment, and published ShipmentDispatchedEvent; ' + | ||
| 'OrderService marked the order Shipped. No orchestrator coordinated any of this — every service just reacted to events. That is the choreography saga.', | ||
| }, | ||
| ] | ||
|
|
||
| /** Narration for the non-happy-path terminals (rendered in place of the next step's panel). */ | ||
| export const TERMINAL_NARRATION: Partial<Record<OrderStatus, string>> = { | ||
| PaymentFailed: | ||
| 'PaymentService declined the payment and published PaymentFailedEvent; OrderService consumed it and marked the order PaymentFailed. ' + | ||
| 'This is the saga’s failure branch — same event mechanics as the happy path, different event.', | ||
| Cancelled: 'The order was cancelled before it shipped. Cancel is allowed from any non-shipped state.', | ||
| } | ||
|
|
||
| /** How far along the happy path a status is. Delivered sits past Shipped; terminals sit where they interrupted. */ | ||
| const PROGRESS: Record<OrderStatus, number> = { | ||
| Placed: 0, | ||
| Paid: 1, | ||
| Shipped: 2, | ||
| Delivered: 3, | ||
| // Failure terminals: how many happy steps completed before the saga stopped. | ||
| PaymentFailed: 1, // Placed completed; payment step failed. | ||
| Cancelled: 1, // Placed completed; cancelled before shipping. | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| /** The saga stopped moving — stop polling. */ | ||
| export function isSagaSettled(status: OrderStatus): boolean { | ||
| return status === 'Shipped' || status === 'Delivered' || status === 'Cancelled' || status === 'PaymentFailed' | ||
| } | ||
|
|
||
| /** | ||
| * Derive each happy-path step's visual state from the order's current status. | ||
| * A step is complete once reached, active if it's the furthest reached and the saga is | ||
| * still moving, failed if the saga terminated at that step, pending otherwise. | ||
| */ | ||
| export function deriveStepStates(status: OrderStatus): StepState[] { | ||
| const progress = PROGRESS[status] | ||
| return SAGA_STEPS.map((_, index) => { | ||
| if (index < progress) return 'complete' | ||
| if (index === progress) { | ||
| if (status === 'PaymentFailed') return 'failed' | ||
| // Cancelled is terminal but NOT a step failure: the DTO carries only the current | ||
| // status, and the backend allows Cancel() from both Placed and Paid — so whether | ||
| // payment ever happened is unknowable here. Marking Paid as failed would misstate | ||
| // a cancel-before-payment; leave the remaining steps pending and let the terminal | ||
| // panel (TERMINAL_NARRATION) tell the story. | ||
| if (status === 'Cancelled') return 'pending' | ||
| // The furthest-reached step: completed if the saga already moved past or settled | ||
| // here (Shipped/Delivered), otherwise it's the live step we're polling on. | ||
| return isSagaSettled(status) ? 'complete' : 'active' | ||
| } | ||
| return 'pending' | ||
| }) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.