Onboarding steps 1 and 2 (mock 1h) with first-run redirect #91

2 changed files with 94 additions and 12 deletions
Showing only changes of commit 4e9b1ea2f9 - Show all commits
@@ -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>