53 - Frontend: scenario list page at / with loading, empty and error states
This commit is contained in:
parent
6fd2243c83
commit
35d83c774f
6 changed files with 304 additions and 10 deletions
25
frontend/src/lib/iri.test.ts
Normal file
25
frontend/src/lib/iri.test.ts
Normal 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
13
frontend/src/lib/iri.ts
Normal 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)
|
||||
}
|
||||
146
frontend/src/pages/ScenariosPage.test.tsx
Normal file
146
frontend/src/pages/ScenariosPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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’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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue