53 - Frontend: wire scenario show route and distinct not-found state
This commit is contained in:
parent
2e73737c29
commit
bfaf5c49ed
4 changed files with 94 additions and 4 deletions
|
|
@ -98,4 +98,44 @@ describe('ScenarioShowPage', () => {
|
||||||
expect(rentPriorityMarker).toBeInTheDocument()
|
expect(rentPriorityMarker).toBeInTheDocument()
|
||||||
expect(diningPriorityMarker).toBeInTheDocument()
|
expect(diningPriorityMarker).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders a distinct not-found state when the scenario 404s (e.g. not owned)', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('http://localhost/api/me', () =>
|
||||||
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
|
),
|
||||||
|
http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () =>
|
||||||
|
HttpResponse.json({ message: 'Not Found' }, { status: 404 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText(/not found/i)).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByText(/could not load this scenario/i),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
// Lock in the accessibility semantics distinction from the error state
|
||||||
|
// (role="alert") — a copy-paste that swaps this to "alert" should fail
|
||||||
|
// here even if the visible copy still says "not found".
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(/not found/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the generic error state for a non-404 failure', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('http://localhost/api/me', () =>
|
||||||
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
|
),
|
||||||
|
http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () =>
|
||||||
|
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
/could not load this scenario/i,
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useParams } from 'react-router'
|
||||||
import AuthedLayout from '../components/layout/AuthedLayout'
|
import AuthedLayout from '../components/layout/AuthedLayout'
|
||||||
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
|
import DigitalProgressBar from '../components/ui/DigitalProgressBar'
|
||||||
import Panel from '../components/ui/Panel'
|
import Panel from '../components/ui/Panel'
|
||||||
import { api } from '../lib/api'
|
import { ApiError, api } from '../lib/api'
|
||||||
import { bucketCapacity } from '../lib/bucketCapacity'
|
import { bucketCapacity } from '../lib/bucketCapacity'
|
||||||
import { formatAllocation } from '../lib/bucketDisplay'
|
import { formatAllocation } from '../lib/bucketDisplay'
|
||||||
import type { Bucket, Scenario } from '../types/api'
|
import type { Bucket, Scenario } from '../types/api'
|
||||||
|
|
@ -11,6 +11,7 @@ import type { Bucket, Scenario } from '../types/api'
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
| { status: 'error' }
|
| { status: 'error' }
|
||||||
|
| { status: 'not-found' }
|
||||||
| { status: 'ready'; scenario: Scenario }
|
| { status: 'ready'; scenario: Scenario }
|
||||||
|
|
||||||
function BucketCard({ bucket }: { bucket: Bucket }) {
|
function BucketCard({ bucket }: { bucket: Bucket }) {
|
||||||
|
|
@ -54,8 +55,15 @@ export default function ScenarioShowPage() {
|
||||||
setState({ status: 'ready', scenario })
|
setState({ status: 'ready', scenario })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error: unknown) => {
|
||||||
if (active) {
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Owner scoping (#50) returns 404 for a scenario you don't own — surface
|
||||||
|
// it as a distinct not-found state rather than a generic failure.
|
||||||
|
if (error instanceof ApiError && error.status === 404) {
|
||||||
|
setState({ status: 'not-found' })
|
||||||
|
} else {
|
||||||
setState({ status: 'error' })
|
setState({ status: 'error' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -80,6 +88,12 @@ export default function ScenarioShowPage() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{state.status === 'not-found' && (
|
||||||
|
<p role="status" className="text-muted-foreground font-mono text-sm">
|
||||||
|
Scenario not found.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{state.status === 'ready' && (
|
{state.status === 'ready' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,38 @@ describe('app router config', () => {
|
||||||
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument()
|
expect(await screen.findByLabelText(/email/i)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('navigating to /scenarios/:id renders the scenario show page', async () => {
|
||||||
|
const SCENARIO_ID = '11111111-1111-1111-1111-111111111111'
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('http://localhost/api/me', () =>
|
||||||
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
|
),
|
||||||
|
http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': `/api/scenarios/${SCENARIO_ID}`,
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Emergency Fund',
|
||||||
|
description: 'Rainy day savings',
|
||||||
|
buckets: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const router = createMemoryRouter(routes, {
|
||||||
|
initialEntries: [`/scenarios/${SCENARIO_ID}`],
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText('Emergency Fund')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('an authenticated user at /login is redirected to the app', async () => {
|
it('an authenticated user at /login is redirected to the app', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost/api/me', () =>
|
http.get('http://localhost/api/me', () =>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate, type RouteObject } from 'react-router'
|
||||||
import LoginPage from './auth/LoginPage'
|
import LoginPage from './auth/LoginPage'
|
||||||
import RegisterPage from './auth/RegisterPage'
|
import RegisterPage from './auth/RegisterPage'
|
||||||
import { RequireAuth, RequireGuest } from './auth/guards'
|
import { RequireAuth, RequireGuest } from './auth/guards'
|
||||||
|
import ScenarioShowPage from './pages/ScenarioShowPage'
|
||||||
import ScenariosPage from './pages/ScenariosPage'
|
import ScenariosPage from './pages/ScenariosPage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,7 +24,10 @@ export const routes: RouteObject[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: <RequireAuth />,
|
element: <RequireAuth />,
|
||||||
children: [{ path: '/', element: <ScenariosPage /> }],
|
children: [
|
||||||
|
{ path: '/', element: <ScenariosPage /> },
|
||||||
|
{ path: '/scenarios/:id', element: <ScenarioShowPage /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
// Unknown paths fall back to the app root (which re-guards).
|
// Unknown paths fall back to the app root (which re-guards).
|
||||||
{ path: '*', element: <Navigate to="/" replace /> },
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue