53 - Frontend: scenario list page at / with loading, empty and error states

This commit is contained in:
myrmidex 2026-07-05 10:39:37 +02:00
parent 6fd2243c83
commit 35d83c774f
6 changed files with 304 additions and 10 deletions

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { iriToId } from './iri'
describe('iriToId', () => {
it('extracts the trailing uuid from an API Platform IRI', () => {
expect(iriToId('/api/scenarios/11111111-1111-1111-1111-111111111111')).toBe(
'11111111-1111-1111-1111-111111111111',
)
})
it('works for any resource IRI, not just scenarios', () => {
expect(iriToId('/api/buckets/abc-123')).toBe('abc-123')
})
it('returns the whole string when there is no slash', () => {
expect(iriToId('bare-id')).toBe('bare-id')
})
// Pinned as a known boundary: a trailing-slash IRI yields an empty id. The
// API never emits one (API Platform IRIs always end in the resource id), so
// this documents the limitation rather than guarding a reachable case.
it('returns an empty string for a trailing-slash IRI', () => {
expect(iriToId('/api/scenarios/')).toBe('')
})
})

13
frontend/src/lib/iri.ts Normal file
View file

@ -0,0 +1,13 @@
/**
* Extract the trailing id from a JSON-LD `@id` IRI.
*
* API Platform IRIs look like `/api/scenarios/{uuid}` or `/api/buckets/{uuid}`;
* the SPA routes on the bare id (`/scenarios/{uuid}`), so we take everything
* after the last slash. `slice(lastIndexOf + 1)` always returns a string (no
* possibly-undefined index access), so there is no unreachable fallback branch.
* A trailing-slash IRI would yield `''`, but API Platform always ends an IRI in
* the resource id, so that case never occurs (pinned in the test as a boundary).
*/
export function iriToId(iri: string): string {
return iri.slice(iri.lastIndexOf('/') + 1)
}

View file

@ -0,0 +1,146 @@
import {
render,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react'
import { delay, http, HttpResponse } from 'msw'
import { MemoryRouter } from 'react-router'
import { describe, expect, it } from 'vitest'
import { AuthProvider } from '../auth/AuthProvider'
import { server } from '../test/server'
import ScenariosPage from './ScenariosPage'
/**
* ScenariosPage renders under AuthProvider (it probes /api/me on mount, same
* as every other authed-page test see LoginPage.test.tsx / router.test.tsx)
* and a MemoryRouter (its rows are <Link>s to /scenarios/{uuid}).
*/
function renderPage() {
render(
<MemoryRouter initialEntries={['/']}>
<AuthProvider>
<ScenariosPage />
</AuthProvider>
</MemoryRouter>,
)
}
describe('ScenariosPage', () => {
it('renders a row per scenario with a link to its show page', async () => {
server.use(
http.get('http://localhost/api/me', () =>
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
),
http.get('http://localhost/api/scenarios', () =>
HttpResponse.json({
'@context': '/api/contexts/Scenario',
'@id': '/api/scenarios',
'@type': 'hydra:Collection',
member: [
{
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
'@type': 'Scenario',
name: 'Emergency Fund',
description: 'Rainy day savings',
},
{
'@id': '/api/scenarios/22222222-2222-2222-2222-222222222222',
'@type': 'Scenario',
name: 'Vacation',
description: null,
},
],
totalItems: 2,
}),
),
)
renderPage()
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
expect(screen.getByText('Vacation')).toBeInTheDocument()
expect(
screen.getByRole('link', { name: /emergency fund/i }),
).toHaveAttribute('href', '/scenarios/11111111-1111-1111-1111-111111111111')
expect(screen.getByRole('link', { name: /vacation/i })).toHaveAttribute(
'href',
'/scenarios/22222222-2222-2222-2222-222222222222',
)
})
it('shows an empty state when the user has no scenarios', async () => {
server.use(
http.get('http://localhost/api/me', () =>
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
),
http.get('http://localhost/api/scenarios', () =>
HttpResponse.json({
'@context': '/api/contexts/Scenario',
'@id': '/api/scenarios',
'@type': 'hydra:Collection',
member: [],
totalItems: 0,
}),
),
)
renderPage()
expect(
await screen.findByText(/don.t have any scenarios|no scenarios/i),
).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
it('shows a loading state while scenarios are being fetched', async () => {
server.use(
http.get('http://localhost/api/me', () =>
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
),
http.get('http://localhost/api/scenarios', async () => {
await delay(50)
return HttpResponse.json({
'@context': '/api/contexts/Scenario',
'@id': '/api/scenarios',
'@type': 'hydra:Collection',
member: [
{
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
'@type': 'Scenario',
name: 'Emergency Fund',
description: 'Rainy day savings',
},
],
totalItems: 1,
})
}),
)
renderPage()
const loadingMarker = await screen.findByRole('status')
expect(loadingMarker).toBeInTheDocument()
await waitForElementToBeRemoved(loadingMarker)
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows an error state when the scenarios request fails', async () => {
server.use(
http.get('http://localhost/api/me', () =>
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
),
http.get('http://localhost/api/scenarios', () =>
HttpResponse.json({ message: 'boom' }, { status: 500 }),
),
)
renderPage()
expect(await screen.findByRole('alert')).toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
})
})

View file

@ -1,21 +1,89 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router'
import AuthedLayout from '../components/layout/AuthedLayout'
import Button from '../components/ui/Button'
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
import Panel from '../components/ui/Panel'
import { api } from '../lib/api'
import { iriToId } from '../lib/iri'
import type { HydraCollection, ScenarioSummary } from '../types/api'
type LoadState =
| { status: 'loading' }
| { status: 'error' }
| { status: 'ready'; scenarios: ScenarioSummary[] }
// Placeholder for the authenticated app home. The real scenario UI lands in the
// frontend feature tickets (#53#56); for now it shows the primitives + theme.
export default function ScenariosPage() {
const [state, setState] = useState<LoadState>({ status: 'loading' })
useEffect(() => {
let active = true
api
.get<HydraCollection<ScenarioSummary>>('/scenarios')
.then((collection) => {
if (active) {
setState({ status: 'ready', scenarios: collection.member })
}
})
.catch(() => {
if (active) {
setState({ status: 'error' })
}
})
return () => {
active = false
}
}, [])
return (
<AuthedLayout>
<Panel className="mx-auto max-w-md space-y-4">
<div className="mx-auto max-w-2xl space-y-4">
<h2 className="text-primary font-bold uppercase tracking-wider">
Emergency Fund
Scenarios
</h2>
<DigitalProgressBar current={750} capacity={1000} />
<p className="font-digital text-primary text-xl">$750 / $1000</p>
<Button glow>+ Add Bucket</Button>
</Panel>
{state.status === 'loading' && (
<p role="status" className="text-muted-foreground font-mono text-sm">
Loading scenarios
</p>
)}
{state.status === 'error' && (
<p role="alert" className="text-destructive font-mono text-sm">
Could not load your scenarios. Please try again.
</p>
)}
{state.status === 'ready' && state.scenarios.length === 0 && (
<p className="text-muted-foreground font-mono text-sm">
You don&rsquo;t have any scenarios yet.
</p>
)}
{state.status === 'ready' && state.scenarios.length > 0 && (
<ul className="space-y-3">
{state.scenarios.map((scenario) => (
<li key={scenario['@id']}>
<Link
to={`/scenarios/${iriToId(scenario['@id'])}`}
className="block"
>
<Panel className="space-y-1 transition hover:brightness-110">
<h3 className="text-primary font-bold uppercase tracking-wider">
{scenario.name}
</h3>
{scenario.description !== null && (
<p className="text-muted-foreground font-mono text-sm">
{scenario.description}
</p>
)}
</Panel>
</Link>
</li>
))}
</ul>
)}
</div>
</AuthedLayout>
)
}

View file

@ -19,6 +19,22 @@ describe('app router config', () => {
http.get('http://localhost/api/me', () =>
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
),
http.get('http://localhost/api/scenarios', () =>
HttpResponse.json({
'@context': '/api/contexts/Scenario',
'@id': '/api/scenarios',
'@type': 'hydra:Collection',
member: [
{
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
'@type': 'Scenario',
name: 'Emergency Fund',
description: 'Rainy day savings',
},
],
totalItems: 1,
}),
),
)
const router = createMemoryRouter(routes, {
@ -57,6 +73,22 @@ describe('app router config', () => {
http.get('http://localhost/api/me', () =>
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
),
http.get('http://localhost/api/scenarios', () =>
HttpResponse.json({
'@context': '/api/contexts/Scenario',
'@id': '/api/scenarios',
'@type': 'hydra:Collection',
member: [
{
'@id': '/api/scenarios/11111111-1111-1111-1111-111111111111',
'@type': 'Scenario',
name: 'Emergency Fund',
description: 'Rainy day savings',
},
],
totalItems: 1,
}),
),
)
const router = createMemoryRouter(routes, { initialEntries: ['/login'] })

View file

@ -17,3 +17,13 @@ export interface HydraCollection<T> {
member: T[]
totalItems: number
}
/**
* A Scenario as it appears in the collection (`GET /api/scenarios`) the lean
* list shape. Buckets are NOT embedded here (only on the item representation,
* see #64); a distinct type keeps list code from reading `.buckets` off a member.
*/
export interface ScenarioSummary extends HydraResource {
name: string
description: string | null
}