Require method and minutes for a typed-in recipe; pause refresh on every save
The manual recipe form now enables Save only once method and minutes are
set, as POST /api/v1/dinners/{id}/ground requires, and always sends both.
useRegulars watches the Regulars changes in flight with useIsMutating, so
the grounding refresh pauses while an edit or a recipe fix is being saved,
not only while an add or favorite changes the list, and resumes after.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+10
-7
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<Response>((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])
|
||||
|
||||
@@ -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<HTMLFormElement>) {
|
||||
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 })
|
||||
/>
|
||||
</div>
|
||||
{minutesError && <p className={styles.problem}>{minutesError}</p>}
|
||||
{ingredientLines.length > 0 && missing && <p className={styles.problem}>Choose a method and minutes to save.</p>}
|
||||
<Button type="submit" disabled={!canSave}>
|
||||
Save recipe
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user