Compare commits
4 commits
bfaf5c49ed
...
0737ca6334
| Author | SHA1 | Date | |
|---|---|---|---|
| 0737ca6334 | |||
| 44731e7388 | |||
| b1bc41c6d5 | |||
| 030a49da01 |
10 changed files with 843 additions and 11 deletions
|
|
@ -26,9 +26,10 @@ test('register, log in, then log out', async ({ page }) => {
|
||||||
await page.getByLabel(/password/i).fill(password)
|
await page.getByLabel(/password/i).fill(password)
|
||||||
await page.getByRole('button', { name: /log in/i }).click()
|
await page.getByRole('button', { name: /log in/i }).click()
|
||||||
|
|
||||||
// Lands on the authenticated app (the scenarios placeholder + the logout
|
// Lands on the authenticated app: the scenarios list. A brand-new account
|
||||||
// control in the authed layout).
|
// owns nothing, so this is the empty state — not seed data (the stack ships
|
||||||
await expect(page.getByText(/emergency fund/i)).toBeVisible()
|
// no fixtures, so a fresh user genuinely has zero scenarios).
|
||||||
|
await expect(page.getByText(/you don.t have any scenarios yet/i)).toBeVisible()
|
||||||
await expect(page.getByRole('button', { name: /log out/i })).toBeVisible()
|
await expect(page.getByRole('button', { name: /log out/i })).toBeVisible()
|
||||||
|
|
||||||
// --- Log out ------------------------------------------------------------
|
// --- Log out ------------------------------------------------------------
|
||||||
|
|
@ -36,5 +37,5 @@ test('register, log in, then log out', async ({ page }) => {
|
||||||
|
|
||||||
// Back to the login screen; the authed content is gone.
|
// Back to the login screen; the authed content is gone.
|
||||||
await expect(page).toHaveURL(/\/login$/)
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
await expect(page.getByText(/emergency fund/i)).toHaveCount(0)
|
await expect(page.getByText(/you don.t have any scenarios yet/i)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
46
frontend/src/lib/bucketInput.test.ts
Normal file
46
frontend/src/lib/bucketInput.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { dollarsToCents, percentToBasisPoints } from './bucketInput'
|
||||||
|
|
||||||
|
describe('dollarsToCents', () => {
|
||||||
|
it('converts whole dollars to cents', () => {
|
||||||
|
expect(dollarsToCents('100')).toBe(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts a dollar amount with cents', () => {
|
||||||
|
expect(dollarsToCents('99.99')).toBe(9999)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds to the nearest cent', () => {
|
||||||
|
expect(dollarsToCents('1.005')).toBe(101)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts zero', () => {
|
||||||
|
expect(dollarsToCents('0')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an empty string as zero', () => {
|
||||||
|
expect(dollarsToCents('')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('percentToBasisPoints', () => {
|
||||||
|
it('converts a whole percent to basis points', () => {
|
||||||
|
expect(percentToBasisPoints('25')).toBe(2500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts a fractional percent to basis points', () => {
|
||||||
|
expect(percentToBasisPoints('0.5')).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts 100% to 10000 basis points', () => {
|
||||||
|
expect(percentToBasisPoints('100')).toBe(10000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts zero', () => {
|
||||||
|
expect(percentToBasisPoints('0')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats an empty string as zero', () => {
|
||||||
|
expect(percentToBasisPoints('')).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
22
frontend/src/lib/bucketInput.ts
Normal file
22
frontend/src/lib/bucketInput.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Convert human-entered form values into the integer wire units the API stores.
|
||||||
|
* The inverse direction of `bucketDisplay.ts` (which formats wire units for
|
||||||
|
* display): here dollars become cents and percent becomes basis points.
|
||||||
|
*
|
||||||
|
* An empty string is treated as zero (a not-yet-filled field), and rounding is
|
||||||
|
* done via a fixed-precision string to sidestep IEEE-754 drift (`1.005 * 100`
|
||||||
|
* is `100.4999…`, which would truncate the half-cent down without this guard).
|
||||||
|
*/
|
||||||
|
function toScaledInteger(value: string): number {
|
||||||
|
return Math.round(Number((Number(value) * 100).toFixed(4)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dollars (`'99.99'`) → integer cents (`9999`). */
|
||||||
|
export function dollarsToCents(value: string): number {
|
||||||
|
return toScaledInteger(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percent (`'25'`) → integer basis points (`2500`). */
|
||||||
|
export function percentToBasisPoints(value: string): number {
|
||||||
|
return toScaledInteger(value)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { ApiError } from './api'
|
import { ApiError } from './api'
|
||||||
import { parseFieldErrors } from './fieldErrors'
|
import { parseFieldErrors, parseViolations } from './fieldErrors'
|
||||||
|
|
||||||
function error422(body: unknown): ApiError {
|
function error422(body: unknown): ApiError {
|
||||||
return new ApiError(422, JSON.stringify(body))
|
return new ApiError(422, JSON.stringify(body))
|
||||||
|
|
@ -44,3 +44,46 @@ describe('parseFieldErrors', () => {
|
||||||
expect(parseFieldErrors(error422({ errors: { email: [42] } }))).toBeNull()
|
expect(parseFieldErrors(error422({ errors: { email: [42] } }))).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('parseViolations', () => {
|
||||||
|
it('groups a Hydra ConstraintViolationList into a per-field error map, keyed by propertyPath', () => {
|
||||||
|
const result = parseViolations(
|
||||||
|
error422({
|
||||||
|
violations: [
|
||||||
|
{ propertyPath: 'name', message: 'x' },
|
||||||
|
{ propertyPath: 'name', message: 'y' },
|
||||||
|
{ propertyPath: 'priority', message: 'z' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: ['x', 'y'],
|
||||||
|
priority: ['z'],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for a non-422 ApiError', () => {
|
||||||
|
expect(parseViolations(new ApiError(500, 'server error'))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for a non-ApiError', () => {
|
||||||
|
expect(parseViolations(new Error('boom'))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for the auth field-error shape, not the violations shape', () => {
|
||||||
|
expect(
|
||||||
|
parseViolations(error422({ errors: { email: ['Already taken.'] } })),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the body has no violations key', () => {
|
||||||
|
expect(parseViolations(error422({ message: 'bad' }))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when a violation entry is missing propertyPath or message', () => {
|
||||||
|
expect(
|
||||||
|
parseViolations(error422({ violations: [{ propertyPath: 'name' }] })),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,65 @@ export function parseFieldErrors(error: unknown): FieldErrors | null {
|
||||||
|
|
||||||
return parsed.errors as FieldErrors
|
return parsed.errors as FieldErrors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isViolation(
|
||||||
|
value: unknown,
|
||||||
|
): value is { propertyPath: string; message: string } {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'propertyPath' in value &&
|
||||||
|
typeof value.propertyPath === 'string' &&
|
||||||
|
'message' in value &&
|
||||||
|
typeof value.message === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the API Platform `{ violations: [{ propertyPath, message }] }` list
|
||||||
|
* from a 422 ApiError body, grouped into a per-field map keyed by propertyPath
|
||||||
|
* (multiple messages for the same path are collected in order).
|
||||||
|
*
|
||||||
|
* This is the sibling of {@link parseFieldErrors}: API Platform resources emit
|
||||||
|
* this violations shape, while the hand-rolled auth endpoints emit the
|
||||||
|
* `{ errors: { field: [...] } }` shape. Returns null for anything that isn't a
|
||||||
|
* well-formed 422 violations response — including the auth shape — so the
|
||||||
|
* caller can fall back to a generic error message.
|
||||||
|
*/
|
||||||
|
export function parseViolations(error: unknown): FieldErrors | null {
|
||||||
|
if (!(error instanceof ApiError) || error.status !== 422) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(error.body)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed !== 'object' ||
|
||||||
|
parsed === null ||
|
||||||
|
!('violations' in parsed) ||
|
||||||
|
!Array.isArray(parsed.violations)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only accept the contract shape: every entry has a string propertyPath and
|
||||||
|
// message. A malformed entry rejects the whole body rather than dropping it.
|
||||||
|
if (!parsed.violations.every(isViolation)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldErrors: FieldErrors = {}
|
||||||
|
for (const { propertyPath, message } of parsed.violations) {
|
||||||
|
if (!fieldErrors[propertyPath]) {
|
||||||
|
fieldErrors[propertyPath] = []
|
||||||
|
}
|
||||||
|
fieldErrors[propertyPath].push(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fieldErrors
|
||||||
|
}
|
||||||
|
|
|
||||||
306
frontend/src/pages/AddBucketForm.test.tsx
Normal file
306
frontend/src/pages/AddBucketForm.test.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { delay, http, HttpResponse } from 'msw'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { server } from '../test/server'
|
||||||
|
import AddBucketForm from './AddBucketForm'
|
||||||
|
|
||||||
|
const SCENARIO_ID = '11111111-1111-1111-1111-111111111111'
|
||||||
|
const SCENARIO_IRI = `/api/scenarios/${SCENARIO_ID}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AddBucketForm's contract (locked for #54 story 2): it takes the owning
|
||||||
|
* scenario's IRI, the scenario's EXISTING buckets (so it can compute the next
|
||||||
|
* priority itself — no priority field is shown to the user), and an
|
||||||
|
* `onCreated` callback fired after a successful 201 so the host page can
|
||||||
|
* refetch the scenario. No auth harness is needed here — the form talks to
|
||||||
|
* the API directly via `api.post`, which is exercised through MSW, and does
|
||||||
|
* not itself probe `/api/me` (that's AuthProvider's job at the page level).
|
||||||
|
*/
|
||||||
|
function renderForm({
|
||||||
|
existingBuckets = [],
|
||||||
|
onCreated = () => {},
|
||||||
|
}: {
|
||||||
|
existingBuckets?: { priority: number }[]
|
||||||
|
onCreated?: () => void
|
||||||
|
} = {}) {
|
||||||
|
render(
|
||||||
|
<AddBucketForm
|
||||||
|
scenario={SCENARIO_IRI}
|
||||||
|
existingBuckets={existingBuckets}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AddBucketForm', () => {
|
||||||
|
it('keeps the form fields hidden until the "Add bucket" toggle is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm()
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(/name/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits a fixed_limit bucket to /api/buckets as JSON-LD referencing the scenario', async () => {
|
||||||
|
let requestBody: unknown
|
||||||
|
let contentType: string | null = null
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', async ({ request }) => {
|
||||||
|
contentType = request.headers.get('Content-Type')
|
||||||
|
requestBody = await request.json()
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestBody).not.toBeUndefined())
|
||||||
|
expect(contentType).toBe('application/ld+json')
|
||||||
|
expect(requestBody).toMatchObject({
|
||||||
|
scenario: SCENARIO_IRI,
|
||||||
|
name: 'Rent',
|
||||||
|
allocationValue: 10000,
|
||||||
|
type: 'need',
|
||||||
|
allocationType: 'fixed_limit',
|
||||||
|
priority: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes the next priority as one past the highest existing priority', async () => {
|
||||||
|
let requestBody: { priority?: number } | undefined
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', async ({ request }) => {
|
||||||
|
requestBody = (await request.json()) as { priority?: number }
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm({ existingBuckets: [{ priority: 1 }, { priority: 2 }] })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Dining Out')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '50')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestBody).not.toBeUndefined())
|
||||||
|
expect(requestBody?.priority).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('assigns priority 1 for the first bucket in an empty scenario', async () => {
|
||||||
|
let requestBody: { priority?: number } | undefined
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', async ({ request }) => {
|
||||||
|
requestBody = (await request.json()) as { priority?: number }
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm({ existingBuckets: [] })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestBody).not.toBeUndefined())
|
||||||
|
expect(requestBody?.priority).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the submit button while the request is in flight', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', async () => {
|
||||||
|
await delay(50)
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
|
||||||
|
const submitButton = screen.getByRole('button', {
|
||||||
|
name: /save|create|submit/i,
|
||||||
|
})
|
||||||
|
await user.click(submitButton)
|
||||||
|
|
||||||
|
expect(submitButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fires onCreated with the response after a successful submit', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
let created = false
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm({ onCreated: () => (created = true) })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() => expect(created).toBe(true))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks submit with a field error when allocation value is left blank for a fixed_limit bucket', async () => {
|
||||||
|
let requestMade = false
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', () => {
|
||||||
|
requestMade = true
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeInTheDocument()
|
||||||
|
expect(requestMade).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets and collapses the form after a successful add', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: /add bucket/i }),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(screen.queryByLabelText(/name/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
expect(screen.getByLabelText(/name/i)).toHaveValue('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the generic form error for a violation with no inline field', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
violations: [
|
||||||
|
{
|
||||||
|
propertyPath: 'priority',
|
||||||
|
message: 'This priority is already used.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Could not add the bucket. Please try again.'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a name violation inline without the generic form error', async () => {
|
||||||
|
server.use(
|
||||||
|
http.post('http://localhost/api/buckets', () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{
|
||||||
|
violations: [
|
||||||
|
{
|
||||||
|
propertyPath: 'name',
|
||||||
|
message: 'This value is already used.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ status: 422 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderForm()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('This value is already used.'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Could not add the bucket. Please try again.'),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
210
frontend/src/pages/AddBucketForm.tsx
Normal file
210
frontend/src/pages/AddBucketForm.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import Button from '../components/ui/Button'
|
||||||
|
import FieldError from '../components/ui/FieldError'
|
||||||
|
import Input from '../components/ui/Input'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { dollarsToCents } from '../lib/bucketInput'
|
||||||
|
import { parseViolations, type FieldErrors } from '../lib/fieldErrors'
|
||||||
|
import type { BucketAllocationType, BucketType } from '../types/api'
|
||||||
|
|
||||||
|
interface AddBucketFormProps {
|
||||||
|
/** IRI of the owning scenario — sent as the POST body's `scenario`. */
|
||||||
|
scenario: string
|
||||||
|
/** Existing buckets, used only to compute the next (hidden) priority. */
|
||||||
|
existingBuckets: { priority: number }[]
|
||||||
|
/** Fired after a successful create so the host page can refetch. */
|
||||||
|
onCreated: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Priority is unique per scenario and auto-assigned — one past the highest. */
|
||||||
|
function nextPriority(existingBuckets: { priority: number }[]): number {
|
||||||
|
if (existingBuckets.length === 0) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return Math.max(...existingBuckets.map((bucket) => bucket.priority)) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Violation propertyPaths this form renders inline; anything else falls back
|
||||||
|
* to the generic form error so it can't fail silently. */
|
||||||
|
const INLINE_ERROR_FIELDS = new Set(['name', 'allocationValue'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The add-bucket form, hosted on the scenario show page behind an "Add bucket"
|
||||||
|
* toggle. Priority is not shown — it's auto-assigned (unique per scenario). On
|
||||||
|
* submit it POSTs to /api/buckets as JSON-LD (via `api.post`) referencing the
|
||||||
|
* scenario IRI, then calls `onCreated` so the page refetches the scenario.
|
||||||
|
*/
|
||||||
|
export default function AddBucketForm({
|
||||||
|
scenario,
|
||||||
|
existingBuckets,
|
||||||
|
onCreated,
|
||||||
|
}: AddBucketFormProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [type, setType] = useState<BucketType>('need')
|
||||||
|
const [allocationType, setAllocationType] =
|
||||||
|
useState<BucketAllocationType>('fixed_limit')
|
||||||
|
const [allocationValue, setAllocationValue] = useState('')
|
||||||
|
const [bufferMultiplier, setBufferMultiplier] = useState('0')
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
|
||||||
|
function resetFields() {
|
||||||
|
setName('')
|
||||||
|
setType('need')
|
||||||
|
setAllocationType('fixed_limit')
|
||||||
|
setAllocationValue('')
|
||||||
|
setBufferMultiplier('0')
|
||||||
|
setFieldErrors({})
|
||||||
|
setFormError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
setFieldErrors({})
|
||||||
|
setFormError(null)
|
||||||
|
|
||||||
|
// A fixed_limit bucket needs a numeric cap — blank would submit a bogus
|
||||||
|
// value, so reject it inline before hitting the API. `percentage` needs one
|
||||||
|
// too, but that guard rides with the per-allocationType field logic in
|
||||||
|
// TODO(#54 story 4) — `unlimited` legitimately carries no value.
|
||||||
|
if (allocationType === 'fixed_limit' && allocationValue.trim() === '') {
|
||||||
|
setFieldErrors({ allocationValue: ['Please enter an allocation value.'] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true)
|
||||||
|
try {
|
||||||
|
await api.post('/buckets', {
|
||||||
|
scenario,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
allocationType,
|
||||||
|
allocationValue: dollarsToCents(allocationValue),
|
||||||
|
bufferMultiplier,
|
||||||
|
priority: nextPriority(existingBuckets),
|
||||||
|
})
|
||||||
|
// Reset and collapse so the next add starts clean — priority is
|
||||||
|
// auto-assigned off the (soon-refetched) buckets, so nothing to carry.
|
||||||
|
resetFields()
|
||||||
|
setOpen(false)
|
||||||
|
onCreated()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errors = parseViolations(err)
|
||||||
|
if (errors !== null) {
|
||||||
|
setFieldErrors(errors)
|
||||||
|
// Priority is auto-assigned and has no input, so a priority collision
|
||||||
|
// (UniquePriorityPerScenario, 422) would render nowhere — surface any
|
||||||
|
// violation the form can't show inline via the generic form error
|
||||||
|
// rather than failing silently.
|
||||||
|
if (
|
||||||
|
Object.keys(errors).some((path) => !INLINE_ERROR_FIELDS.has(path))
|
||||||
|
) {
|
||||||
|
setFormError('Could not add the bucket. Please try again.')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setFormError('Could not add the bucket. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<Button glow onClick={() => setOpen(true)}>
|
||||||
|
Add bucket
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameErrors = fieldErrors.name
|
||||||
|
const allocationValueErrors = fieldErrors.allocationValue
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
aria-invalid={nameErrors !== undefined}
|
||||||
|
aria-describedby={nameErrors ? 'bucket-name-errors' : undefined}
|
||||||
|
/>
|
||||||
|
<FieldError id="bucket-name-errors" messages={nameErrors} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-primary block font-mono text-sm uppercase tracking-wide">
|
||||||
|
Type
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="border-primary text-primary w-full border-2 bg-background px-3 py-2 font-mono"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as BucketType)}
|
||||||
|
>
|
||||||
|
<option value="need">Need</option>
|
||||||
|
<option value="want">Want</option>
|
||||||
|
<option value="overflow">Overflow</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-primary block font-mono text-sm uppercase tracking-wide">
|
||||||
|
Cap type
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
className="border-primary text-primary w-full border-2 bg-background px-3 py-2 font-mono"
|
||||||
|
value={allocationType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAllocationType(e.target.value as BucketAllocationType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="fixed_limit">Fixed limit</option>
|
||||||
|
<option value="percentage">Percentage</option>
|
||||||
|
<option value="unlimited">Unlimited</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
label="Allocation value"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={allocationValue}
|
||||||
|
onChange={(e) => setAllocationValue(e.target.value)}
|
||||||
|
aria-invalid={allocationValueErrors !== undefined}
|
||||||
|
aria-describedby={
|
||||||
|
allocationValueErrors ? 'bucket-allocation-errors' : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldError
|
||||||
|
id="bucket-allocation-errors"
|
||||||
|
messages={allocationValueErrors}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Buffer multiplier"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
value={bufferMultiplier}
|
||||||
|
onChange={(e) => setBufferMultiplier(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formError !== null && (
|
||||||
|
<p role="alert" className="text-destructive font-mono text-sm">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" glow disabled={pending} className="w-full">
|
||||||
|
{pending ? 'Saving…' : 'Save bucket'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { render, screen } from '@testing-library/react'
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
import { http, HttpResponse } from 'msw'
|
import { http, HttpResponse } from 'msw'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
@ -89,16 +90,71 @@ describe('ScenarioShowPage', () => {
|
||||||
// Priority: assert each bucket's own priority is shown somewhere within
|
// Priority: assert each bucket's own priority is shown somewhere within
|
||||||
// its own rendered content, rather than a page-wide loose number match
|
// its own rendered content, rather than a page-wide loose number match
|
||||||
// (which could coincidentally match allocation/other digits instead).
|
// (which could coincidentally match allocation/other digits instead).
|
||||||
const rentPriorityMarker = await screen.findByText((_, element) =>
|
const rentPriorityMarker = await screen.findByText(
|
||||||
element?.textContent === 'Priority 1',
|
(_, element) => element?.textContent === 'Priority 1',
|
||||||
)
|
)
|
||||||
const diningPriorityMarker = await screen.findByText((_, element) =>
|
const diningPriorityMarker = await screen.findByText(
|
||||||
element?.textContent === 'Priority 2',
|
(_, element) => element?.textContent === 'Priority 2',
|
||||||
)
|
)
|
||||||
expect(rentPriorityMarker).toBeInTheDocument()
|
expect(rentPriorityMarker).toBeInTheDocument()
|
||||||
expect(diningPriorityMarker).toBeInTheDocument()
|
expect(diningPriorityMarker).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the empty-buckets copy and renders no bucket cards when the scenario has none', 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({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': `/api/scenarios/${SCENARIO_ID}`,
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Emergency Fund',
|
||||||
|
description: 'Rainy day savings',
|
||||||
|
buckets: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/this scenario has no buckets yet/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.queryByText('Priority', { exact: false }),
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the scenario name without a description paragraph when description is null', 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({
|
||||||
|
'@context': '/api/contexts/Scenario',
|
||||||
|
'@id': `/api/scenarios/${SCENARIO_ID}`,
|
||||||
|
'@type': 'Scenario',
|
||||||
|
name: 'Vacation',
|
||||||
|
description: null,
|
||||||
|
buckets: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Vacation')).toBeInTheDocument()
|
||||||
|
// No description was supplied, so no description paragraph should render
|
||||||
|
// anywhere on the page — assert on the heading's sibling instead of a
|
||||||
|
// page-wide text search that could false-positive against other copy.
|
||||||
|
const heading = screen.getByRole('heading', { name: 'Vacation' })
|
||||||
|
expect(heading.parentElement).not.toBeNull()
|
||||||
|
expect(heading.parentElement!.querySelectorAll('p')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('renders a distinct not-found state when the scenario 404s (e.g. not owned)', async () => {
|
it('renders a distinct not-found state when the scenario 404s (e.g. not owned)', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('http://localhost/api/me', () =>
|
http.get('http://localhost/api/me', () =>
|
||||||
|
|
@ -128,7 +184,10 @@ describe('ScenarioShowPage', () => {
|
||||||
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
HttpResponse.json({ id: 'u1', email: 'a@b.com' }),
|
||||||
),
|
),
|
||||||
http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () =>
|
http.get(`http://localhost/api/scenarios/${SCENARIO_ID}`, () =>
|
||||||
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
|
HttpResponse.json(
|
||||||
|
{ message: 'Internal Server Error' },
|
||||||
|
{ status: 500 },
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -138,4 +197,65 @@ describe('ScenarioShowPage', () => {
|
||||||
/could not load this scenario/i,
|
/could not load this scenario/i,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows a newly added bucket after a successful add-bucket submit, via a refetch', async () => {
|
||||||
|
// Stateful GET: the first call (initial mount) returns an empty scenario,
|
||||||
|
// every call after the POST returns the scenario WITH the new bucket. This
|
||||||
|
// proves the add flow re-fetches the scenario rather than relying on an
|
||||||
|
// optimistic insert — the new bucket can only appear via the second GET.
|
||||||
|
let scenarioCreated = false
|
||||||
|
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: scenarioCreated
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
'@id': '/api/buckets/22222222-2222-2222-2222-222222222222',
|
||||||
|
'@type': 'Bucket',
|
||||||
|
name: 'Rent',
|
||||||
|
type: 'need',
|
||||||
|
priority: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
allocationType: 'fixed_limit',
|
||||||
|
allocationValue: 10000,
|
||||||
|
startingAmount: 0,
|
||||||
|
bufferMultiplier: '0.00',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.post('http://localhost/api/buckets', () => {
|
||||||
|
scenarioCreated = true
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ '@id': '/api/buckets/22222222-2222-2222-2222-222222222222' },
|
||||||
|
{ status: 201 },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/this scenario has no buckets yet/i),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /add bucket/i }))
|
||||||
|
await user.type(screen.getByLabelText(/name/i), 'Rent')
|
||||||
|
await user.type(screen.getByLabelText(/allocation/i), '100')
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', { name: /save|create|submit/i }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText('Rent')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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'
|
||||||
|
import AddBucketForm from './AddBucketForm'
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
|
|
@ -44,6 +45,9 @@ function BucketCard({ bucket }: { bucket: Bucket }) {
|
||||||
export default function ScenarioShowPage() {
|
export default function ScenarioShowPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const [state, setState] = useState<LoadState>({ status: 'loading' })
|
const [state, setState] = useState<LoadState>({ status: 'loading' })
|
||||||
|
// Bumped after a successful add so the effect re-fetches the scenario, pulling
|
||||||
|
// in the new bucket from the embedded-buckets GET (the single source of truth).
|
||||||
|
const [reloadKey, setReloadKey] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
|
@ -71,7 +75,7 @@ export default function ScenarioShowPage() {
|
||||||
return () => {
|
return () => {
|
||||||
active = false
|
active = false
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id, reloadKey])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthedLayout>
|
<AuthedLayout>
|
||||||
|
|
@ -120,6 +124,12 @@ export default function ScenarioShowPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AddBucketForm
|
||||||
|
scenario={state.scenario['@id']}
|
||||||
|
existingBuckets={state.scenario.buckets}
|
||||||
|
onCreated={() => setReloadKey((key) => key + 1)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,18 @@ describe('ScenariosPage', () => {
|
||||||
'href',
|
'href',
|
||||||
'/scenarios/22222222-2222-2222-2222-222222222222',
|
'/scenarios/22222222-2222-2222-2222-222222222222',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Emergency Fund has a description — its row renders it. Vacation's
|
||||||
|
// description is null — scope the assertion to Vacation's own row (not
|
||||||
|
// the whole page) so it can't pass by coincidentally finding Emergency
|
||||||
|
// Fund's description text elsewhere.
|
||||||
|
const emergencyFundRow = screen.getByRole('link', {
|
||||||
|
name: /emergency fund/i,
|
||||||
|
})
|
||||||
|
expect(emergencyFundRow).toHaveTextContent('Rainy day savings')
|
||||||
|
|
||||||
|
const vacationRow = screen.getByRole('link', { name: /vacation/i })
|
||||||
|
expect(vacationRow.querySelectorAll('p')).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows an empty state when the user has no scenarios', async () => {
|
it('shows an empty state when the user has no scenarios', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue