diff --git a/web/src/ideas/QuickDinnersPage.test.tsx b/web/src/ideas/QuickDinnersPage.test.tsx index e020f80..891e13e 100644 --- a/web/src/ideas/QuickDinnersPage.test.tsx +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -279,6 +279,28 @@ describe('QuickDinnersPage', () => { expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() }) + it('marks an add that settles while another tab is shown', async () => { + const added = Promise.withResolvers() + const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(addButton(ORZO.name)) + await waitFor(() => expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)) + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + added.resolve(jsonResponse(201, dinnerBody('dn-1', ORZO.name, { status: 'quest_log', origin: 'quick' }))) + await waitFor(() => expect(queryClient.isMutating()).toBe(0)) + + fireEvent.click(tab('30 min or less')) + expect(within(row(ORZO.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() + expect(addButton(QUESADILLAS.name)).toBeEnabled() + expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1) + }) + it('shows its failure in its row and lets it be pressed again', async () => { let fail = true const { jobs } = quickServer(['j1'], { @@ -387,6 +409,72 @@ describe('QuickDinnersPage', () => { ]) }) + it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => { + let under30 = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: (init) => { + const { kind } = JSON.parse(String(init?.body)) as { kind: string } + if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' }) + return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' }) + }, + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + await renderPage() + await screen.findByRole('alert') + + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'crockpot', exclude: [] }, + ]) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(screen.queryByRole('alert')).toBeNull() + expect(calls(fetchMock, QUICK)).toHaveLength(3) + }) + + it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => { + let under30 = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: (init) => { + const { kind } = JSON.parse(String(init?.body)) as { kind: string } + if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' }) + under30++ + return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' }) + }, + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + fireEvent.click(await showMore()) + await screen.findByRole('alert') + + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + expect(rowNames()).toEqual([ORZO.name]) + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + expect(calls(fetchMock, QUICK)).toHaveLength(3) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] }) + expect(screen.queryByRole('alert')).toBeNull() + }) + it.each([ ['ai_failed', "The AI assistant couldn't come up with ideas."], ['no_ideas_found', 'No ideas came back with a recipe to match.'], diff --git a/web/src/ideas/QuickDinnersPage.tsx b/web/src/ideas/QuickDinnersPage.tsx index b13f305..5fe4c96 100644 --- a/web/src/ideas/QuickDinnersPage.tsx +++ b/web/src/ideas/QuickDinnersPage.tsx @@ -59,9 +59,20 @@ function meta(idea: QuickIdea): string { // QuickRow is one idea (mock 1j): its washed photo, name and meta line, and // the teal "+" that adds it to the Quest log, a check once it is there. // ideaRef is null while its job runs: the server takes an idea only once -// the job has succeeded. -function QuickRow({ idea, ideaRef, added, onAdded }: { idea: QuickIdea; ideaRef: IdeaRef | null; added: boolean; onAdded: () => void }) { - const questLog = useAddIdeaToQuestLog() +// the job has succeeded. onAdded marks the idea added, also if the row has +// unmounted before the add settles. +function QuickRow({ + idea, + ideaRef, + added, + onAdded, +}: { + idea: QuickIdea + ideaRef: IdeaRef | null + added: boolean + onAdded: (idea: IdeaRef) => void +}) { + const questLog = useAddIdeaToQuestLog(onAdded) return (
  • @@ -84,7 +95,7 @@ function QuickRow({ idea, ideaRef, added, onAdded }: { idea: QuickIdea; ideaRef: tone="teal" className={styles.add} disabled={ideaRef === null || questLog.isPending} - onClick={() => questLog.mutate(ideaRef!, { onSuccess: onAdded })} + onClick={() => questLog.mutate(ideaRef!)} > @@ -108,20 +119,20 @@ function Skeletons({ count }: { count: number }) { // the tab is first shown; what it finds is kept for the session. function QuickTabPanel({ kind }: { kind: QuickKind }) { const [tab, update] = useQuickTab(kind) - const find = useFindQuickIdeas(kind) - const { mutate } = find + const { mutate } = useFindQuickIdeas(kind) // A page being asked for, also by this tab before it was last shown. const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0 const job = useJob(QUICK_JOB_KIND, tab.jobId) const names = shownNames(tab) // ask asks for the next page, excluding every name shown; Retry asks - // again for the page that failed. + // again for the page that failed. A page that could not start stays the + // tab's startError until Retry: nothing asks again by itself. const ask = () => mutate(names) // The first page, asked for once. const asked = useRef(false) - const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && !starting + const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting useEffect(() => { if (!fresh || asked.current) return asked.current = true @@ -152,7 +163,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) { else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t)) }, [jobId, ideas, exhausted, update]) - const markAdded = (key: string) => update((t) => ({ ...t, added: [...t.added, key] })) + const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] })) // The rows of every page found, then of the page being found, in one // keyed list: a page kept with the tab's pages keeps its rows, and with @@ -164,10 +175,10 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) { let next = null if (starting) { next = - } else if (find.isError) { + } else if (tab.startError !== null) { next = (
  • - Couldn't start looking for Quick dinners. {find.error.message} + Couldn't start looking for Quick dinners. {tab.startError.message}
  • ) } else if (jobId !== null && failure !== null && !exhausted) { @@ -203,7 +214,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) { // Show me more once the tab is between pages; the server takes at most // MAX_EXCLUDE names to leave out, so past that there is no more to ask. - const between = !starting && !find.isError && jobId === null && tab.pages.length > 0 + const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0 const allShown = tab.ended || names.length > MAX_EXCLUDE const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded') @@ -212,7 +223,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) {

    {helpers[kind]}

    diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts index d0f14ed..0963f3d 100644 --- a/web/src/ideas/api.ts +++ b/web/src/ideas/api.ts @@ -220,11 +220,12 @@ export type QuickPage = { jobId: string; ideas: QuickIdea[] } // QuickTab is what one tab of Quick dinners has found this session: the // pages found, the job finding the next one, whether the server has said -// there are no more, and the ideas added to the Quest log, as +// there are no more, why the last page asked for could not start, kept +// until Retry asks again, and the ideas added to the Quest log, as // "jobId:index". -export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; added: string[] } +export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] } -const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, added: [] } +const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] } export function quickTabKey(kind: QuickKind) { return ['ideas', 'quick', kind] as const @@ -258,20 +259,23 @@ export function useQuickTab(kind: QuickKind) { // useFindQuickIdeas starts finding a page of Quick dinner ideas of kind, // none named in exclude (POST /api/v1/ideas/quick), and makes its job the -// tab's job, also if the screen has been left meanwhile. Its mutationKey is -// the tab's, so a tab shown again knows a page is still being asked for. +// tab's job, or its failure the tab's startError, also if the screen has +// been left meanwhile. Its mutationKey is the tab's, so a tab shown again +// knows a page is still being asked for. export function useFindQuickIdeas(kind: QuickKind) { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ mutationKey: quickTabKey(kind), mutationFn: async (exclude: string[]): Promise => { + queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null })) const body = await apiFetch(QUICK_PATH, { method: 'POST', body: { kind, exclude } }) if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${QUICK_PATH}: response has no jobId`) const jobId = body.jobId queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId })) return jobId }, + onError: (startError: Error) => queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })), }) } @@ -285,8 +289,9 @@ function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight // useAddIdeaToQuestLog adds an idea to the Quest log (POST // /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the // dinner's name. Asking again answers the same dinner. The Quest log is -// read again after it. -export function useAddIdeaToQuestLog() { +// read again after it, and onAdded told, also if the caller has unmounted +// meanwhile. +export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ @@ -294,7 +299,10 @@ export function useAddIdeaToQuestLog() { const path = ideaPath(idea, 'quest-log') return parseDinner(`POST ${path}`, await apiFetch(path, { method: 'POST' })).name }, - onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + onSuccess: (_name, idea) => { + onAdded?.(idea) + return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) + }, }) }