Skip to content

Commit 97e7faa

Browse files
authored
feat: add breadcrumb navigation to all detail pages (#1293)
* feat: add Breadcrumbs shared component with home icon and chevron separators * feat: add breadcrumbs to accounts, internal accounts, and payments detail pages * feat: add breadcrumbs to parties, positions, and ledger detail pages * feat: add breadcrumbs to reconciliation, starlark, market-data, mappings, and tenants detail pages * fix: update reconciliation test to handle breadcrumb duplicating run ID text * fix: update positions tests to use breadcrumb link; add breadcrumbs to market-data error states * fix: update reconciliation tests for breadcrumb-based navigation * fix: update mappings and parties tests for breadcrumb-based navigation The <h1>Mapping Details</h1> and <h1>Party Details</h1> headings were removed when replacing back navigation with breadcrumbs. Update tests to assert on the breadcrumb link to the parent section instead, and use getAllByText for mapping name which now appears in both breadcrumb and header. * fix: improve test assertions per CodeRabbit feedback - mappings: scope 'renders mapping name after loading' to heading role instead of permissive getAllByText check - parties: restore actual tab-switching assertions in tab tests, add userEvent import for click interactions * fix: update E2E tests for breadcrumb-based navigation Replace back-button testid and 'Party Details' heading assertions with breadcrumb link assertions in positions and parties E2E specs. The old back-button and Party Details heading were removed when breadcrumbs were introduced across all detail pages. * ci: trigger full CI re-run on latest test fixes * fix: mock useAuth in parties test to support audit trail tab click AuditTrailTab uses useAuthenticatedFetch which calls useAuth internally. Without an AuthProvider or useAuth mock, clicking the Audit Trail tab throws 'useAuth must be used within an AuthProvider'. * fix: add TenantProvider mock to party detail tests The AuditTrail component uses useAuthenticatedFetch which requires TenantProvider context. Add useTenantContext mock to fix the test that was failing when switching to the audit trail tab. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 90ebfda commit 97e7faa

19 files changed

Lines changed: 251 additions & 175 deletions

File tree

frontend/e2e/positions.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,19 @@ test.describe('Position detail page', () => {
6060
).toBeVisible({ timeout: 10_000 })
6161
})
6262

63-
test('renders back button on position detail page', async ({ authenticatedPage }) => {
63+
test('renders breadcrumb back link on position detail page', async ({ authenticatedPage }) => {
6464
await navigateTo(authenticatedPage, '/positions/non-existent-log-id')
6565
await expect(
66-
authenticatedPage.getByTestId('back-button'),
66+
authenticatedPage.getByRole('link', { name: 'Positions' }),
6767
).toBeVisible({ timeout: 10_000 })
6868
})
6969

70-
test('back button navigates to positions list', async ({ authenticatedPage }) => {
70+
test('breadcrumb back link navigates to positions list', async ({ authenticatedPage }) => {
7171
await navigateTo(authenticatedPage, '/positions/non-existent-log-id')
7272
await expect(
73-
authenticatedPage.getByTestId('back-button'),
73+
authenticatedPage.getByRole('link', { name: 'Positions' }),
7474
).toBeVisible({ timeout: 10_000 })
75-
await authenticatedPage.getByTestId('back-button').click()
75+
await authenticatedPage.getByRole('link', { name: 'Positions' }).click()
7676
await expect(authenticatedPage.getByRole('heading', { name: 'Positions' })).toBeVisible()
7777
})
7878
})

frontend/e2e/specs/parties.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,16 @@ test.describe('Party detail navigation', () => {
7474
await expect(firstRow).toBeVisible()
7575
await firstRow.click()
7676
await expect(page).toHaveURL(/\/parties\/[a-zA-Z0-9-]+/)
77-
await expect(page.getByRole('heading', { name: 'Party Details' })).toBeVisible()
77+
await expect(page.getByRole('link', { name: 'Parties' })).toBeVisible()
7878
})
7979

8080
test('shows Party ID not found for missing partyId param', async ({ authenticatedPage: page }) => {
8181
// Directly verify that the error message renders for an invalid ID
8282
await navigateTo(page, '/parties/00000000-0000-0000-0000-000000000000')
83-
// Page should render — it will show either the header or an error state
83+
// Page should render — it will show either the party data or an error state
8484
// (the component renders even without backend data)
8585
await expect(
86-
page.getByRole('heading', { name: 'Party Details' }).or(
86+
page.getByRole('link', { name: 'Parties' }).or(
8787
page.getByText('Party not found')
8888
)
8989
).toBeVisible()
@@ -100,7 +100,7 @@ test.describe('Party detail — 8-tab layout', () => {
100100
} else {
101101
await page.locator('table tbody tr').first().click()
102102
}
103-
await expect(page.getByRole('heading', { name: 'Party Details' })).toBeVisible()
103+
await expect(page.getByRole('link', { name: 'Parties' })).toBeVisible()
104104
})
105105

106106
test('renders all 8 tab triggers', async ({ authenticatedPage: page }) => {
@@ -141,7 +141,7 @@ test.describe('Party header component', () => {
141141
return
142142
}
143143
await page.locator('table tbody tr').first().click()
144-
await expect(page.getByRole('heading', { name: 'Party Details' })).toBeVisible()
144+
await expect(page.getByRole('link', { name: 'Parties' })).toBeVisible()
145145
})
146146

147147
test('renders party header section', async ({ authenticatedPage: page }) => {
@@ -169,7 +169,7 @@ test.describe('Tab switching', () => {
169169
} else {
170170
await page.locator('table tbody tr').first().click()
171171
}
172-
await expect(page.getByRole('heading', { name: 'Party Details' })).toBeVisible()
172+
await expect(page.getByRole('link', { name: 'Parties' })).toBeVisible()
173173
})
174174

175175
test('Overview tab renders without error', async ({ authenticatedPage: page }) => {
@@ -244,7 +244,7 @@ test.describe('Tab keyboard navigation', () => {
244244
} else {
245245
await page.locator('table tbody tr').first().click()
246246
}
247-
await expect(page.getByRole('heading', { name: 'Party Details' })).toBeVisible()
247+
await expect(page.getByRole('link', { name: 'Parties' })).toBeVisible()
248248
})
249249

250250
test('ArrowRight moves focus to next tab', async ({ authenticatedPage: page }) => {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Link } from 'react-router-dom'
2+
import { ChevronRight, HomeIcon } from 'lucide-react'
3+
4+
export interface BreadcrumbItem {
5+
label: string
6+
href?: string
7+
}
8+
9+
export interface BreadcrumbsProps {
10+
items: BreadcrumbItem[]
11+
}
12+
13+
export function Breadcrumbs({ items }: BreadcrumbsProps) {
14+
return (
15+
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm text-muted-foreground">
16+
<Link
17+
to="/"
18+
className="flex items-center hover:text-foreground transition-colors"
19+
aria-label="Dashboard"
20+
>
21+
<HomeIcon className="h-4 w-4" />
22+
</Link>
23+
{items.map((item, index) => {
24+
const isLast = index === items.length - 1
25+
return (
26+
<span key={index} className="flex items-center gap-1">
27+
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
28+
{isLast || !item.href ? (
29+
<span className={isLast ? 'text-foreground font-medium' : undefined}>
30+
{item.label}
31+
</span>
32+
) : (
33+
<Link to={item.href} className="hover:text-foreground transition-colors">
34+
{item.label}
35+
</Link>
36+
)}
37+
</span>
38+
)
39+
})}
40+
</nav>
41+
)
42+
}

frontend/src/components/shared/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,8 @@ export type { CreateValuationFeatureDialogProps, AccountType } from './create-va
1717
export { EntityLink } from './entity-link';
1818
export type { EntityLinkProps, EntityType } from './entity-link';
1919

20+
export { Breadcrumbs } from './breadcrumbs';
21+
export type { BreadcrumbsProps, BreadcrumbItem } from './breadcrumbs';
22+
2023
export { DetailSkeleton } from './detail-skeleton';
2124
export type { DetailSkeletonProps } from './detail-skeleton';

frontend/src/pages/accounts/[accountId].tsx

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import * as React from 'react'
2-
import { Link, useParams } from 'react-router-dom'
2+
import { useParams } from 'react-router-dom'
33
import { useQuery } from '@tanstack/react-query'
4-
import { ChevronLeftIcon } from 'lucide-react'
54
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
65
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
76
import { Button } from '@/components/ui/button'
87
import { StatusBadge } from '@/components/shared/status-badge'
98
import { TimeDisplay } from '@/components/shared/time-display'
109
import { MoneyDisplay } from '@/components/shared/money-display'
11-
import { AuditTrail, EntityLink } from '@/components/shared'
10+
import { AuditTrail, EntityLink, Breadcrumbs } from '@/components/shared'
1211
import { ConnectError, Code } from '@connectrpc/connect'
1312
import { useApiClients } from '@/api/context'
1413
import { useTenantContext } from '@/contexts/tenant-context'
@@ -75,14 +74,7 @@ function AccountDetailSkeleton() {
7574
function AccountNotFound() {
7675
return (
7776
<div data-testid="account-not-found" className="p-6">
78-
<Link
79-
to="/accounts"
80-
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
81-
aria-label="Back to Accounts"
82-
>
83-
<ChevronLeftIcon className="h-4 w-4" />
84-
Accounts
85-
</Link>
77+
<Breadcrumbs items={[{ label: 'Accounts', href: '/accounts' }, { label: 'Not found' }]} />
8678
<div className="mt-8 text-center">
8779
<h2 className="text-xl font-semibold">Account not found</h2>
8880
<p className="mt-2 text-sm text-muted-foreground">
@@ -441,15 +433,13 @@ export function AccountDetailPage() {
441433

442434
return (
443435
<div className="p-6">
444-
{/* Back navigation */}
445-
<Link
446-
to="/accounts"
447-
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
448-
aria-label="Back to Accounts"
449-
>
450-
<ChevronLeftIcon className="h-4 w-4" />
451-
Accounts
452-
</Link>
436+
{/* Breadcrumb navigation */}
437+
<Breadcrumbs
438+
items={[
439+
{ label: 'Accounts', href: '/accounts' },
440+
{ label: account.accountId },
441+
]}
442+
/>
453443

454444
{/* Page header */}
455445
<div className="mt-4 flex flex-wrap items-start justify-between gap-4">

frontend/src/pages/internal-accounts/[accountId].tsx

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import * as React from 'react'
2-
import { Link, useParams } from 'react-router-dom'
2+
import { useParams } from 'react-router-dom'
33
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
4-
import { ChevronLeftIcon } from 'lucide-react'
54
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
65
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
76
import { Button } from '@/components/ui/button'
87
import { StatusBadge } from '@/components/shared/status-badge'
98
import { TimeDisplay } from '@/components/shared/time-display'
109
import { MoneyDisplay } from '@/components/shared/money-display'
11-
import { AuditTrail } from '@/components/shared'
10+
import { AuditTrail, Breadcrumbs } from '@/components/shared'
1211
import { ConnectError, Code } from '@connectrpc/connect'
1312
import { useApiClients } from '@/api/context'
1413
import { useTenantContext } from '@/contexts/tenant-context'
@@ -92,14 +91,7 @@ function InternalAccountDetailSkeleton() {
9291
function InternalAccountNotFound() {
9392
return (
9493
<div data-testid="internal-account-not-found" className="p-6">
95-
<Link
96-
to="/internal-accounts"
97-
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
98-
aria-label="Back to Internal Accounts"
99-
>
100-
<ChevronLeftIcon className="h-4 w-4" />
101-
Internal Accounts
102-
</Link>
94+
<Breadcrumbs items={[{ label: 'Internal Accounts', href: '/internal-accounts' }, { label: 'Not found' }]} />
10395
<div className="mt-8 text-center">
10496
<h2 className="text-xl font-semibold">Account not found</h2>
10597
<p className="mt-2 text-sm text-muted-foreground">
@@ -321,14 +313,7 @@ export function InternalAccountDetailPage() {
321313
if (isError) {
322314
return (
323315
<div className="p-6">
324-
<Link
325-
to="/internal-accounts"
326-
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
327-
aria-label="Back to Internal Accounts"
328-
>
329-
<ChevronLeftIcon className="h-4 w-4" />
330-
Internal Accounts
331-
</Link>
316+
<Breadcrumbs items={[{ label: 'Internal Accounts', href: '/internal-accounts' }, { label: 'Error' }]} />
332317
<p className="mt-4 text-sm text-destructive">Failed to load account details. Please try again.</p>
333318
</div>
334319
)
@@ -342,15 +327,13 @@ export function InternalAccountDetailPage() {
342327

343328
return (
344329
<div className="p-6">
345-
{/* Back navigation */}
346-
<Link
347-
to="/internal-accounts"
348-
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
349-
aria-label="Back to Internal Accounts"
350-
>
351-
<ChevronLeftIcon className="h-4 w-4" />
352-
Internal Accounts
353-
</Link>
330+
{/* Breadcrumb navigation */}
331+
<Breadcrumbs
332+
items={[
333+
{ label: 'Internal Accounts', href: '/internal-accounts' },
334+
{ label: account.accountCode },
335+
]}
336+
/>
354337

355338
{/* Page header */}
356339
<div className="mt-4 flex flex-wrap items-start justify-between gap-4">

frontend/src/pages/ledger/booking-log-detail.tsx

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Link, useParams } from 'react-router-dom'
1+
import { useParams } from 'react-router-dom'
22
import { useQuery } from '@tanstack/react-query'
33
import type { ColumnDef } from '@tanstack/react-table'
44
import {
@@ -10,7 +10,7 @@ import { useApiClients } from '@/api/context'
1010
import { useTenantContext } from '@/contexts/tenant-context'
1111
import { tenantKeys } from '@/lib/query-keys'
1212
import { StatusBadge } from '@/components/shared/status-badge'
13-
import { TimeDisplay, EntityLink } from '@/components/shared'
13+
import { TimeDisplay, EntityLink, Breadcrumbs } from '@/components/shared'
1414
import { MoneyDisplay } from '@/components/shared/money-display'
1515
import {
1616
Table,
@@ -251,13 +251,7 @@ export function BookingLogDetailPage() {
251251
if (isLoading) {
252252
return (
253253
<div className="space-y-6">
254-
<div className="flex items-center gap-2">
255-
<Link to="/ledger" className="text-sm text-muted-foreground hover:underline">
256-
Ledger
257-
</Link>
258-
<span className="text-muted-foreground">/</span>
259-
<span className="text-sm">Loading...</span>
260-
</div>
254+
<Breadcrumbs items={[{ label: 'Ledger', href: '/ledger' }, { label: 'Loading...' }]} />
261255
<div className="h-32 animate-pulse rounded-lg bg-muted" />
262256
</div>
263257
)
@@ -266,13 +260,7 @@ export function BookingLogDetailPage() {
266260
if (isError || !data) {
267261
return (
268262
<div className="space-y-6">
269-
<div className="flex items-center gap-2">
270-
<Link to="/ledger" className="text-sm text-muted-foreground hover:underline">
271-
Ledger
272-
</Link>
273-
<span className="text-muted-foreground">/</span>
274-
<span className="text-sm">Error</span>
275-
</div>
263+
<Breadcrumbs items={[{ label: 'Ledger', href: '/ledger' }, { label: 'Error' }]} />
276264
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
277265
Failed to load booking log. Please try again.
278266
</div>
@@ -283,13 +271,12 @@ export function BookingLogDetailPage() {
283271
return (
284272
<div className="space-y-6">
285273
{/* Breadcrumb navigation */}
286-
<div className="flex items-center gap-2">
287-
<Link to="/ledger" className="text-sm text-muted-foreground hover:underline">
288-
Ledger
289-
</Link>
290-
<span className="text-muted-foreground">/</span>
291-
<span className="font-mono text-sm">{data.id}</span>
292-
</div>
274+
<Breadcrumbs
275+
items={[
276+
{ label: 'Ledger', href: '/ledger' },
277+
{ label: data.id },
278+
]}
279+
/>
293280

294281
{/* Booking log header with metadata */}
295282
<BookingLogHeader bookingLog={data} />

frontend/src/pages/mappings/[mappingId].test.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,15 +104,18 @@ describe('MappingDetailPage', () => {
104104
renderAtRoute(<MappingDetailPage />, '/gateway-mappings/mapping-abc')
105105

106106
await waitFor(() => {
107-
expect(screen.getByRole('heading', { name: /mapping details/i })).toBeInTheDocument()
107+
// Breadcrumb link to parent section
108+
const gatewayMappingsLink = screen.getByRole('link', { name: 'Gateway Mappings' })
109+
expect(gatewayMappingsLink).toBeInTheDocument()
110+
expect(gatewayMappingsLink).toHaveAttribute('href', '/mappings')
108111
})
109112
})
110113

111114
it('renders mapping name after loading', async () => {
112115
renderAtRoute(<MappingDetailPage />, '/gateway-mappings/mapping-abc')
113116

114117
await waitFor(() => {
115-
expect(screen.getByText('Stripe Webhook')).toBeInTheDocument()
118+
expect(screen.getByRole('heading', { name: 'Stripe Webhook' })).toBeInTheDocument()
116119
})
117120
})
118121

frontend/src/pages/mappings/[mappingId].tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react'
22
import { useParams } from 'react-router-dom'
3+
import { Breadcrumbs } from '@/components/shared'
34
import { useQuery, useMutation } from '@tanstack/react-query'
45
import { Card } from '@/components/ui/card'
56
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -438,15 +439,18 @@ export function MappingDetailPage() {
438439
}
439440

440441
if (isLoading) {
441-
return <DetailSkeleton fieldCount={4} tabCount={3} showBackNav={false} />
442+
return (
443+
<div className="space-y-6">
444+
<Breadcrumbs items={[{ label: 'Gateway Mappings', href: '/mappings' }, { label: 'Loading...' }]} />
445+
<DetailSkeleton fieldCount={4} tabCount={3} showBackNav={false} />
446+
</div>
447+
)
442448
}
443449

444450
if (isError || !data) {
445451
return (
446452
<div className="space-y-6">
447-
<div>
448-
<h1 className="text-3xl font-bold tracking-tight">Mapping Details</h1>
449-
</div>
453+
<Breadcrumbs items={[{ label: 'Gateway Mappings', href: '/mappings' }, { label: 'Error' }]} />
450454
<Card className="p-6">
451455
<p className="text-destructive">Failed to load mapping.</p>
452456
</Card>
@@ -456,9 +460,12 @@ export function MappingDetailPage() {
456460

457461
return (
458462
<div className="space-y-6">
459-
<div>
460-
<h1 className="text-3xl font-bold tracking-tight">Mapping Details</h1>
461-
</div>
463+
<Breadcrumbs
464+
items={[
465+
{ label: 'Gateway Mappings', href: '/mappings' },
466+
{ label: data.name },
467+
]}
468+
/>
462469

463470
<Card>
464471
<MappingHeader mapping={data} />

0 commit comments

Comments
 (0)