diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 095ca20..b0a39e5 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' +import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' @@ -39,8 +39,9 @@ export type NewRegular = { name: string } | { recipe_url: string } // RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional. export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }> -// ManualRecipe is a recipe the household types in. -export type ManualRecipe = { ingredients: string[]; steps: string[]; minutes?: number; method?: Method } +// ManualRecipe is a recipe the household types in; the server needs its +// minutes and method. +export type ManualRecipe = { ingredients: string[]; steps: string[]; minutes: number; method: Method } // RecipeFix is a POST /api/v1/dinners/{id}/ground body: exactly one way to // ground the dinner. @@ -144,11 +145,13 @@ function dinnerPath(id: string): string { // useRegulars is the household's Regulars, in the server's order. While any // of them is still finding its recipe it is read again every -// GROUNDING_POLL_MS, except while a change is in flight (whose settling -// reads it again anyway), so a poll never overwrites an optimistic change. +// GROUNDING_POLL_MS, except while any change is in flight (whose settling +// reads it again anyway), so a poll never overwrites a change being saved. +// Watching the changes in flight re-renders the hook as each one starts and +// settles, which pauses and resumes the poll. export function useRegulars() { const { apiFetch } = useApi() - const queryClient = useQueryClient() + const changing = useIsMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0 return useQuery({ queryKey: REGULARS_LIST_KEY, queryFn: async () => { @@ -160,7 +163,7 @@ export function useRegulars() { select: shown, refetchInterval: (query) => { const list = query.state.data - if (list === undefined || queryClient.isMutating({ mutationKey: REGULARS_MUTATION_KEY }) > 0) return false + if (list === undefined || changing) return false return list.some((regular) => regular.grounding_status === 'pending') ? GROUNDING_POLL_MS : false }, }) diff --git a/web/src/settings/RegularsSettingsPage.test.tsx b/web/src/settings/RegularsSettingsPage.test.tsx index 83a8718..ba7371c 100644 --- a/web/src/settings/RegularsSettingsPage.test.tsx +++ b/web/src/settings/RegularsSettingsPage.test.tsx @@ -447,6 +447,34 @@ describe('RegularsSettingsPage', () => { expect(within(row('Pizza night')).getByText('Oven ยท 35 min')).toBeInTheDocument() }) + it('saves a typed-in recipe only once its method and minutes are set', async () => { + const server = stubServer([PIZZA], { + 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES), + }) + await renderPage() + + openRow('Pizza night') + fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' })) + const save = fixSheet().getByRole('button', { name: 'Save recipe' }) + fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), { target: { value: '1 pizza dough' } }) + expect(fixSheet().getByText('Choose a method and minutes to save.')).toBeInTheDocument() + expect(save).toBeDisabled() + + fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } }) + expect(save).toBeDisabled() + fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: '' } }) + fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } }) + expect(save).toBeDisabled() + + // Even a submit that skips the button sends nothing. + fireEvent.submit(save.closest('form')!) + expect(sent(server.fetchMock)).toEqual([]) + + fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } }) + expect(fixSheet().queryByText('Choose a method and minutes to save.')).not.toBeInTheDocument() + expect(save).toBeEnabled() + }) + it('switches to editing the Regular', async () => { stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES) }) await renderPage() @@ -480,6 +508,67 @@ describe('RegularsSettingsPage', () => { expect(reads(server.fetchMock)).toBe(3) }) + // held answers a request only when release is called, so a test can look + // at the page while a save is in flight. + function held(answer: () => Response) { + let release = () => {} + const response = new Promise((resolve) => { + release = () => resolve(answer()) + }) + return { respond: () => response, release: () => release() } + } + + it('pauses the refresh while an edit is being saved, then resumes', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const edited = { ...TACOS, name: 'Fish tacos' } + const patch = held(() => jsonResponse(200, edited)) + const server = stubServer([TACOS, CHILI], { 'PATCH /api/v1/dinners/d1': patch.respond }) + await renderPage() + + openRow('Beef tacos') + const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' })) + fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } }) + fireEvent.click(sheet.getByRole('button', { name: 'Save changes' })) + await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1)) + + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3)) + expect(reads(server.fetchMock)).toBe(1) + + server.regulars = [edited, CHILI] + patch.release() + // Settling reads the list once, then the refresh carries on. + await waitFor(() => expect(reads(server.fetchMock)).toBe(2)) + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS)) + await waitFor(() => expect(reads(server.fetchMock)).toBe(3)) + }) + + it('pauses the refresh while a recipe fix is being saved, then resumes', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const fixed = { ...PIZZA, grounding_status: 'grounded' as const, grounding_error: null } + const ground = held(() => jsonResponse(200, fixed)) + const server = stubServer([CHILI, PIZZA], { + 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }), + 'POST /api/v1/dinners/d3/ground': ground.respond, + }) + await renderPage() + + openRow('Pizza night') + const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' })) + fireEvent.click(sheet.getByRole('radio', { name: 'Paste a link' })) + fireEvent.change(sheet.getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza' } }) + fireEvent.click(sheet.getByRole('button', { name: 'Use this recipe' })) + await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1)) + + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3)) + expect(reads(server.fetchMock)).toBe(1) + + server.regulars = [CHILI, fixed] + ground.release() + await waitFor(() => expect(reads(server.fetchMock)).toBe(2)) + await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS)) + await waitFor(() => expect(reads(server.fetchMock)).toBe(3)) + }) + it('does not poll when no Regular is finding its recipe', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) const server = stubServer([TACOS, PIZZA]) diff --git a/web/src/settings/RegularsSettingsPage.tsx b/web/src/settings/RegularsSettingsPage.tsx index 98db463..6464606 100644 --- a/web/src/settings/RegularsSettingsPage.tsx +++ b/web/src/settings/RegularsSettingsPage.tsx @@ -550,15 +550,13 @@ function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) const stepsId = useId() const minutesError = minutesProblem(minutes) const ingredientLines = lines(ingredients) - const canSave = ingredientLines.length > 0 && minutesError === null + const missing = method === '' || minutes === '' + const canSave = ingredientLines.length > 0 && !missing && minutesError === null function onSave(event: FormEvent) { event.preventDefault() if (!canSave) return - const manual: ManualRecipe = { ingredients: ingredientLines, steps: lines(steps) } - if (minutes !== '') manual.minutes = Number(minutes) - if (method !== '') manual.method = method - onSubmit(manual) + onSubmit({ ingredients: ingredientLines, steps: lines(steps), minutes: Number(minutes), method }) } return ( @@ -609,6 +607,7 @@ function ManualEntry({ onSubmit }: { onSubmit: (manual: ManualRecipe) => void }) /> {minutesError &&

{minutesError}

} + {ingredientLines.length > 0 && missing &&

Choose a method and minutes to save.

}