Keep each onboarding add and removal refusal on screen
CI / ci (push) Successful in 2m18s
CI / ci (pull_request) Successful in 2m19s

Step 2 read the refusal from the mutation hooks, which only report their
latest change: an add refused while another add was in flight was rolled
back with no message, and the same for removals. Each change now records
its own refusal, keyed by the change, so it stays shown whatever the
other changes in flight do; trying the same change again replaces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 17:14:01 -04:00
co-authored by Claude Opus 5.5
parent 760ad9c421
commit 4e9b1ea2f9
2 changed files with 94 additions and 12 deletions
@@ -206,6 +206,43 @@ describe('onboarding step 2 (mock 1h)', () => {
expect(regularNames()).toEqual(['Chili'])
})
it('says why an add was refused while another add is still unanswered', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Burgers', 'Chili'])
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
// The other add's answer does not hide the refusal.
created(server, server.held[1]!, 'd1')
await screen.findByRole('button', { name: 'Remove Chili' })
expect(screen.getByRole('alert')).toHaveTextContent('Could not add Burgers: duplicate_dinner message')
expect(regularNames()).toEqual(['Chili'])
})
it('says why each of two overlapping adds was refused', async () => {
const server = await regularsStep()
fireEvent.click(screen.getByRole('button', { name: 'Add Burgers' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Add Chili' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
server.held[0]!.answer(errorResponse(409, 'duplicate_dinner'))
server.held[1]!.answer(errorResponse(500, 'internal'))
await vi.waitFor(() =>
expect(screen.getAllByRole('alert').map((alert) => alert.textContent)).toEqual([
'Could not add Burgers: duplicate_dinner message',
'Could not add Chili: internal message',
]),
)
expect(regularNames()).toEqual([])
})
it('removes a Regular at once and archives it on the server', async () => {
const server = await regularsStep([
{ id: 'd1', name: 'Beef tacos' },
@@ -234,6 +271,30 @@ describe('onboarding step 2 (mock 1h)', () => {
expect(regularNames()).toEqual(['Beef tacos', 'Pizza night'])
})
it('puts a refused removal back and says why while another removal is still unanswered', async () => {
const server = await regularsStep([
{ id: 'd1', name: 'Beef tacos' },
{ id: 'd2', name: 'Pizza night' },
{ id: 'd3', name: 'Chili' },
])
fireEvent.click(screen.getByRole('button', { name: 'Remove Beef tacos' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
fireEvent.click(screen.getByRole('button', { name: 'Remove Pizza night' }))
await vi.waitFor(() => expect(server.held).toHaveLength(2))
expect(regularNames()).toEqual(['Chili'])
server.held[0]!.answer(errorResponse(500, 'internal'))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
expect(regularNames()).toEqual(['Beef tacos', 'Chili'])
// The other removal's answer does not hide the refusal.
server.regulars.splice(1, 1)
server.held[1]!.answer(new Response(null, { status: 204 }))
await vi.waitFor(() => expect(server.fetchMock.mock.calls.filter(([path]) => String(path).includes('status=regular'))).toHaveLength(2))
await vi.waitFor(() => expect(regularNames()).toEqual(['Beef tacos', 'Chili']))
expect(screen.getByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
})
it('enables "Next: notifications" once the server has saved 3 Regulars', async () => {
const server = await regularsStep([
{ id: 'd1', name: 'Beef tacos' },
+33 -12
View File
@@ -31,6 +31,10 @@ function sameName(a: string, b: string): boolean {
return a.trim().toLowerCase() === b.trim().toLowerCase()
}
// Refusal is one add or removal the server turned down. key names the
// change, so trying it again replaces its refusal and no other.
type Refusal = { key: string; text: string }
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
// the dinners it makes on repeat. Each add and remove shows at once and is
// undone if the server refuses it.
@@ -40,6 +44,10 @@ export function RegularsStep() {
const add = useAddRegular()
const remove = useRemoveRegular()
const [draft, setDraft] = useState('')
// Each change keeps its own refusal: the mutation hooks only report their
// latest change, which would hide a refusal while another change is in
// flight.
const [refusals, setRefusals] = useState<Refusal[]>([])
const list = regulars.data ?? []
const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(regular.name, pick)))
// Next waits for every add to be answered, so a refused add's error shows
@@ -47,11 +55,29 @@ export function RegularsStep() {
const adding = list.some((regular) => regular.pending)
const saved = list.filter((regular) => !regular.pending).length
function track(key: string, what: string, change: Promise<unknown>) {
setRefusals((all) => all.filter((refusal) => refusal.key !== key))
change.catch((error: Error) => {
setRefusals((all) => [
...all.filter((refusal) => refusal.key !== key),
{ key, text: `Could not ${what}: ${error.message}` },
])
})
}
function addRegular(name: string) {
track(`add:${name.toLowerCase()}`, `add ${name}`, add.mutateAsync(name))
}
function removeRegular(regular: Regular) {
track(`remove:${regular.id}`, `remove ${regular.name}`, remove.mutateAsync(regular))
}
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draft.trim()
if (name === '') return
add.mutate(name)
addRegular(name)
setDraft('')
}
@@ -89,16 +115,11 @@ export function RegularsStep() {
</span>
</button>
</form>
{add.isError && (
<p role="alert" className={styles.problem}>
Could not add {add.variables}: {add.error.message}
{refusals.map((refusal) => (
<p key={refusal.key} role="alert" className={styles.problem}>
{refusal.text}
</p>
)}
{remove.isError && (
<p role="alert" className={styles.problem}>
Could not remove {remove.variables.name}: {remove.error.message}
</p>
)}
))}
<h2 className={styles.groupLabel}>Your regulars · {list.length}</h2>
{regulars.isPending && <Skeleton className={styles.status} />}
@@ -117,7 +138,7 @@ export function RegularsStep() {
label={regular.name}
className={regular.pending && styles.pending}
aria-busy={regular.pending}
onRemove={regular.pending ? undefined : () => remove.mutate(regular)}
onRemove={regular.pending ? undefined : () => removeRegular(regular)}
/>
</li>
))}
@@ -130,7 +151,7 @@ export function RegularsStep() {
<ul className={styles.chips}>
{picks.map((pick) => (
<li key={pick}>
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => add.mutate(pick)}>
<button type="button" className={styles.pick} aria-label={`Add ${pick}`} onClick={() => addRegular(pick)}>
<span aria-hidden="true">+</span>
{pick}
</button>