diff --git a/frontend/src/pages/AddBucketForm.test.tsx b/frontend/src/pages/AddBucketForm.test.tsx new file mode 100644 index 0000000..1abbb04 --- /dev/null +++ b/frontend/src/pages/AddBucketForm.test.tsx @@ -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( + , + ) +} + +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() + }) +}) diff --git a/frontend/src/pages/AddBucketForm.tsx b/frontend/src/pages/AddBucketForm.tsx new file mode 100644 index 0000000..a4b1b54 --- /dev/null +++ b/frontend/src/pages/AddBucketForm.tsx @@ -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('need') + const [allocationType, setAllocationType] = + useState('fixed_limit') + const [allocationValue, setAllocationValue] = useState('') + const [bufferMultiplier, setBufferMultiplier] = useState('0') + const [fieldErrors, setFieldErrors] = useState({}) + const [formError, setFormError] = useState(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) { + 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 ( + + ) + } + + const nameErrors = fieldErrors.name + const allocationValueErrors = fieldErrors.allocationValue + + return ( +
+
+ setName(e.target.value)} + aria-invalid={nameErrors !== undefined} + aria-describedby={nameErrors ? 'bucket-name-errors' : undefined} + /> + +
+ + + + + +
+ setAllocationValue(e.target.value)} + aria-invalid={allocationValueErrors !== undefined} + aria-describedby={ + allocationValueErrors ? 'bucket-allocation-errors' : undefined + } + /> + +
+ + setBufferMultiplier(e.target.value)} + /> + + {formError !== null && ( +

+ {formError} +

+ )} + + +
+ ) +} diff --git a/frontend/src/pages/ScenarioShowPage.test.tsx b/frontend/src/pages/ScenarioShowPage.test.tsx index a73d6cd..b7bcd74 100644 --- a/frontend/src/pages/ScenarioShowPage.test.tsx +++ b/frontend/src/pages/ScenarioShowPage.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' import { MemoryRouter, Route, Routes } from 'react-router' import { describe, expect, it } from 'vitest' @@ -89,11 +90,11 @@ describe('ScenarioShowPage', () => { // Priority: assert each bucket's own priority is shown somewhere within // its own rendered content, rather than a page-wide loose number match // (which could coincidentally match allocation/other digits instead). - const rentPriorityMarker = await screen.findByText((_, element) => - element?.textContent === 'Priority 1', + const rentPriorityMarker = await screen.findByText( + (_, element) => element?.textContent === 'Priority 1', ) - const diningPriorityMarker = await screen.findByText((_, element) => - element?.textContent === 'Priority 2', + const diningPriorityMarker = await screen.findByText( + (_, element) => element?.textContent === 'Priority 2', ) expect(rentPriorityMarker).toBeInTheDocument() expect(diningPriorityMarker).toBeInTheDocument() @@ -121,7 +122,9 @@ describe('ScenarioShowPage', () => { expect( await screen.findByText(/this scenario has no buckets yet/i), ).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 () => { @@ -181,7 +184,10 @@ describe('ScenarioShowPage', () => { HttpResponse.json({ id: 'u1', email: 'a@b.com' }), ), 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, ) }) + + 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() + }) }) diff --git a/frontend/src/pages/ScenarioShowPage.tsx b/frontend/src/pages/ScenarioShowPage.tsx index c5c5882..fc4d5f4 100644 --- a/frontend/src/pages/ScenarioShowPage.tsx +++ b/frontend/src/pages/ScenarioShowPage.tsx @@ -7,6 +7,7 @@ import { ApiError, api } from '../lib/api' import { bucketCapacity } from '../lib/bucketCapacity' import { formatAllocation } from '../lib/bucketDisplay' import type { Bucket, Scenario } from '../types/api' +import AddBucketForm from './AddBucketForm' type LoadState = | { status: 'loading' } @@ -44,6 +45,9 @@ function BucketCard({ bucket }: { bucket: Bucket }) { export default function ScenarioShowPage() { const { id } = useParams<{ id: string }>() const [state, setState] = useState({ 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(() => { let active = true @@ -71,7 +75,7 @@ export default function ScenarioShowPage() { return () => { active = false } - }, [id]) + }, [id, reloadKey]) return ( @@ -120,6 +124,12 @@ export default function ScenarioShowPage() { ))} )} + + setReloadKey((key) => key + 1)} + /> )}