diff --git a/frontend/src/lib/iri.test.ts b/frontend/src/lib/iri.test.ts new file mode 100644 index 0000000..5a78956 --- /dev/null +++ b/frontend/src/lib/iri.test.ts @@ -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('') + }) +}) diff --git a/frontend/src/lib/iri.ts b/frontend/src/lib/iri.ts new file mode 100644 index 0000000..6d23f58 --- /dev/null +++ b/frontend/src/lib/iri.ts @@ -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) +} diff --git a/frontend/src/pages/ScenariosPage.test.tsx b/frontend/src/pages/ScenariosPage.test.tsx new file mode 100644 index 0000000..41318e6 --- /dev/null +++ b/frontend/src/pages/ScenariosPage.test.tsx @@ -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 s to /scenarios/{uuid}). + */ +function renderPage() { + render( + + + + + , + ) +} + +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() + }) +}) diff --git a/frontend/src/pages/ScenariosPage.tsx b/frontend/src/pages/ScenariosPage.tsx index 544cc51..5bcf39b 100644 --- a/frontend/src/pages/ScenariosPage.tsx +++ b/frontend/src/pages/ScenariosPage.tsx @@ -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({ status: 'loading' }) + + useEffect(() => { + let active = true + + api + .get>('/scenarios') + .then((collection) => { + if (active) { + setState({ status: 'ready', scenarios: collection.member }) + } + }) + .catch(() => { + if (active) { + setState({ status: 'error' }) + } + }) + + return () => { + active = false + } + }, []) + return ( - +

- Emergency Fund + Scenarios

- -

$750 / $1000

- - + + {state.status === 'loading' && ( +

+ Loading scenarios… +

+ )} + + {state.status === 'error' && ( +

+ Could not load your scenarios. Please try again. +

+ )} + + {state.status === 'ready' && state.scenarios.length === 0 && ( +

+ You don’t have any scenarios yet. +

+ )} + + {state.status === 'ready' && state.scenarios.length > 0 && ( +
    + {state.scenarios.map((scenario) => ( +
  • + + +

    + {scenario.name} +

    + {scenario.description !== null && ( +

    + {scenario.description} +

    + )} +
    + +
  • + ))} +
+ )} +
) } diff --git a/frontend/src/router.test.tsx b/frontend/src/router.test.tsx index a4554b3..cd2a6d4 100644 --- a/frontend/src/router.test.tsx +++ b/frontend/src/router.test.tsx @@ -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'] }) diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 7347603..f746bfd 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -17,3 +17,13 @@ export interface HydraCollection { 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 +}