54 - Add-bucket form posts a new bucket and refetches the list
Some checks failed
CI / backend (push) Failing after 10m30s
CI / frontend (push) Successful in 1m5s
CI / e2e (push) Has been skipped

This commit is contained in:
myrmidex 2026-07-06 23:37:24 +02:00
parent 44731e7388
commit 0737ca6334
4 changed files with 600 additions and 7 deletions

View 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()
})
})

View 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>
)
}

View file

@ -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,11 +90,11 @@ 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()
@ -121,7 +122,9 @@ describe('ScenarioShowPage', () => {
expect( expect(
await screen.findByText(/this scenario has no buckets yet/i), await screen.findByText(/this scenario has no buckets yet/i),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.queryByText('Priority', { exact: false })).not.toBeInTheDocument() expect(
screen.queryByText('Priority', { exact: false }),
).not.toBeInTheDocument()
}) })
it('renders the scenario name without a description paragraph when description is null', async () => { it('renders the scenario name without a description paragraph when description is null', async () => {
@ -181,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 },
),
), ),
) )
@ -191,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()
})
}) })

View file

@ -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>