diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 3f2904e..a63e447 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -427,6 +427,77 @@ describe('PlanPage', () => { expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) }) + it('pushes nothing again until the changed plan is shown, even after leaving the week and coming back', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + + // Leaving the week and coming back while the changed plan is read + // remounts the board: the push is still busy, and sends nothing again. + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + await screen.findByText('Beef tacos') + expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' })) + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.") + expect(screen.getByText('Lasagna')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled() + + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + + it('shows the changed plan read while the week was left, and pushes it', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + fireEvent.click(screen.getByRole('button', { name: 'Next week' })) + expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument() + await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))) + read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])) + fireEvent.click(screen.getByRole('button', { name: 'Previous week' })) + + // The board comes back on the changed plan: Push sends that one. + expect(await screen.findByText('Lasagna')).toBeInTheDocument() + expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument() + expect(screen.getByRole('alert')).toHaveTextContent('The plan changed while it was being pushed; push it again.') + push = () => jsonResponse(200, syncedNow()) + fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`]) + }) + it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => { let plan = planBody('draft') let job = draftJob('running') diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 946ee3d..62e42cc 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -243,7 +243,7 @@ function WeekBoard({ const [confirming, setConfirming] = useState(false) const swap = useSwapDay(weekStart) const push = usePushToCalendar(weekStart) - const pushing = push.isPending + const pushing = push.pending // The date of the day being swapped, while the swap sheet is open. const [swappingDate, setSwappingDate] = useState(null) const label = weekLabel(weekStart) @@ -302,11 +302,13 @@ function WeekBoard({ const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null) // A push sends the plan shown: it waits for a draft or a swap of the week - // to settle, and neither starts while it runs. + // to settle, and neither starts while it runs, even across leaving the + // week and coming back. const canPush = plan.isSuccess && plan.data !== null && !drafting && !swap.pending && !pushing function pushToCalendar() { if (!canPush) return swap.dismiss() + push.reset() push.mutate() } @@ -336,7 +338,7 @@ function WeekBoard({ else start(false) } - const failure = push.isError ? ( + const failure = push.error !== null ? ( ) : swap.failed !== null ? ( swapIn(swap.failed!.swap)}> diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index 491efda..fd580f4 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -276,27 +276,80 @@ export function useSwapDay(weekStart: string): SwapDay { } } +// pushKey keys the pushes of the week starting on weekStart in the mutation +// cache, where they outlive the Draft board that sent them. +function pushKey(weekStart: string) { + return ['plans', weekStart, 'push'] as const +} + +// PushToCalendar is the push of one week, as the mutation cache holds it: +// whether it is pending, and why the latest failed, if it failed. +export type PushToCalendar = { + pending: boolean + error: Error | null + mutate: () => void + reset: () => void +} + // usePushToCalendar pushes the plan of the week starting on weekStart to the // chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The // server answers with the plan as synced, which replaces the one shown, as a // swap's answer does. A push refused because the plan changed meanwhile -// reads the plan again, and stays pending until that read lands: nothing is -// pushed again before the changed plan is shown. One Google refused the -// grant for reads the Google Calendar connection again, which is now -// reconnect required. -export function usePushToCalendar(weekStart: string) { +// reads the plan again, whether or not the week is shown, and stays pending +// until that read lands: nothing is pushed again before the changed plan is +// the one shown. One Google refused the grant for reads the Google Calendar +// connection again, which is now reconnect required. +// +// The week's pushes live in the mutation cache, not in the component, as its +// swaps do, so leaving the week and coming back keeps one pending. reset +// leaves settled pushes behind; it is refused while one is pending. +export function usePushToCalendar(weekStart: string): PushToCalendar { const { apiFetch } = useApi() const queryClient = useQueryClient() const key = planKey(weekStart) - return useMutation({ - mutationFn: async (): Promise => { - const path = `${planPath(weekStart)}/push-calendar` - return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) - }, - onSuccess: (plan) => queryClient.setQueryData(key, plan), - onError: async (error) => { - noteGoogleFailure(queryClient, error) - if (error instanceof ApiError && error.code === 'plan_changed') await queryClient.invalidateQueries({ queryKey: key }) - }, + const mutationKey = pushKey(weekStart) + const mutations = queryClient.getMutationCache() + // The week's pushes, in the order sent. + const pushes = useMutationState({ + filters: { mutationKey }, + select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }), }) + const latest = pushes.at(-1) + + function mutate() { + const mutation = mutations.build(queryClient, { + mutationKey, + // Kept until reset: a push pending in a week left must still be found + // when it is shown again. + gcTime: Infinity, + mutationFn: async () => { + const path = `${planPath(weekStart)}/push-calendar` + return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) + }, + onSuccess: (plan) => queryClient.setQueryData(key, plan), + onError: async (error) => { + noteGoogleFailure(queryClient, error) + if (error instanceof ApiError && error.code === 'plan_changed') { + await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' }) + } + }, + }) + // A failure is the mutation's state, which error reports. + mutation.execute().catch(() => undefined) + } + + function reset() { + const week = mutations.findAll({ mutationKey }) + if (week.some((mutation) => mutation.state.status === 'pending')) { + throw new Error(`The week of ${weekStart} has a push pending, which cannot be reset.`) + } + for (const mutation of week) mutations.remove(mutation) + } + + return { + pending: pushes.some((push) => push.status === 'pending'), + error: latest?.status === 'error' ? latest.error! : null, + mutate, + reset, + } }