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 AuthedLayout from '../components/layout/AuthedLayout'
|
||||||
import Button from '../components/ui/Button'
|
|
||||||
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
|
|
||||||
import Panel from '../components/ui/Panel'
|
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() {
|
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 (
|
return (
|
||||||
<AuthedLayout>
|
<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">
|
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||||
Emergency Fund
|
Scenarios
|
||||||
</h2>
|
</h2>
|
||||||
<DigitalProgressBar current={750} capacity={1000} />
|
|
||||||
<p className="font-digital text-primary text-xl">$750 / $1000</p>
|
{state.status === 'loading' && (
|
||||||
<Button glow>+ Add Bucket</Button>
|
<p role="status" className="text-muted-foreground font-mono text-sm">
|
||||||
</Panel>
|
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>
|
</AuthedLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,22 @@ describe('app router config', () => {
|
||||||
http.get('http://localhost/api/me', () =>
|
http.get('http://localhost/api/me', () =>
|
||||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
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, {
|
const router = createMemoryRouter(routes, {
|
||||||
|
|
@ -57,6 +73,22 @@ describe('app router config', () => {
|
||||||
http.get('http://localhost/api/me', () =>
|
http.get('http://localhost/api/me', () =>
|
||||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
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'] })
|
const router = createMemoryRouter(routes, { initialEntries: ['/login'] })
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,13 @@ export interface HydraCollection<T> {
|
||||||
member: T[]
|
member: T[]
|
||||||
totalItems: number
|
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