diff --git a/web/src/plan/PlanPage.test.tsx b/web/src/plan/PlanPage.test.tsx index 6f8584d..3f2904e 100644 --- a/web/src/plan/PlanPage.test.tsx +++ b/web/src/plan/PlanPage.test.tsx @@ -392,6 +392,41 @@ describe('PlanPage', () => { expect(planReads(fetchMock)).toBe(2) }) + it('pushes nothing again until the changed plan is read and shown', async () => { + let read: () => Response | Promise = () => jsonResponse(200, planBody('draft')) + let push = () => errorResponse(409, 'plan_changed') + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => read(), + [`POST ${PUSH}`]: () => push(), + }) + await renderPage() + + await screen.findByText('Beef tacos') + const reread = deferred() + read = () => reread.response + const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' }) + fireEvent.click(pushButton) + await waitFor(() => expect(planReads(fetchMock)).toBe(2)) + // While the changed plan is read, the push stays busy: neither Push + // nor Retry sends it again. + 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(screen.getByText('Beef tacos')).toBeInTheDocument() + expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]) + + 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(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/api.ts b/web/src/plan/api.ts index 6477089..491efda 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -280,8 +280,10 @@ export function useSwapDay(weekStart: string): SwapDay { // 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; one Google refused the grant for reads the Google -// Calendar connection again, which is now reconnect required. +// 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) { const { apiFetch } = useApi() const queryClient = useQueryClient() @@ -292,9 +294,9 @@ export function usePushToCalendar(weekStart: string) { return parsePlan(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST' }), false) }, onSuccess: (plan) => queryClient.setQueryData(key, plan), - onError: (error) => { - if (error instanceof ApiError && error.code === 'plan_changed') void queryClient.invalidateQueries({ queryKey: key }) + onError: async (error) => { noteGoogleFailure(queryClient, error) + if (error instanceof ApiError && error.code === 'plan_changed') await queryClient.invalidateQueries({ queryKey: key }) }, }) } diff --git a/web/src/settings/CalendarSettingsPage.module.css b/web/src/settings/CalendarSettingsPage.module.css index ccfbc5f..3c6f7bf 100644 --- a/web/src/settings/CalendarSettingsPage.module.css +++ b/web/src/settings/CalendarSettingsPage.module.css @@ -129,6 +129,12 @@ font-weight: 400; } +.reconnectStatus { + display: flex; + flex-direction: column; + gap: 10px; +} + .reconnectText { margin: 0; font-family: var(--font-body); diff --git a/web/src/settings/CalendarSettingsPage.test.tsx b/web/src/settings/CalendarSettingsPage.test.tsx index 4baa7b1..6803202 100644 --- a/web/src/settings/CalendarSettingsPage.test.tsx +++ b/web/src/settings/CalendarSettingsPage.test.tsx @@ -279,7 +279,14 @@ describe('CalendarSettingsPage', () => { }) await renderPage() - expect(await screen.findByRole('region', { name: 'Reconnect Google Calendar' })).toBeInTheDocument() + const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' }) + // Why and what to do are announced as the screen turns to the banner. + const alert = screen.getByRole('alert') + expect(banner).toContainElement(alert) + expect(alert).toHaveTextContent( + 'Google access was revoked or expired — reconnect' + 'Nothing can be pushed to Calendar until you connect again.', + ) + expect(within(alert).queryByRole('link')).not.toBeInTheDocument() expect(screen.queryByRole('combobox')).not.toBeInTheDocument() expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument() }) diff --git a/web/src/settings/CalendarSettingsPage.tsx b/web/src/settings/CalendarSettingsPage.tsx index 40af793..ffb78b0 100644 --- a/web/src/settings/CalendarSettingsPage.tsx +++ b/web/src/settings/CalendarSettingsPage.tsx @@ -96,7 +96,8 @@ function Connection({ connection }: { connection: GoogleConnection }) { } // ReconnectBanner is the grape banner of a grant Google refused, with why -// and Reconnect. +// and Reconnect. Why and what to do are an alert, as ErrorBanner's message +// is: a calendar request that meets the refusal turns the screen to it. function ReconnectBanner({ error }: { error: string | null }) { const titleId = useId() return ( @@ -104,8 +105,10 @@ function ReconnectBanner({ error }: { error: string | null }) {

Reconnect Google Calendar

- {error !== null &&

{error}

} -

Nothing can be pushed to Calendar until you connect again.

+
+ {error !== null &&

{error}

} +

Nothing can be pushed to Calendar until you connect again.

+
Reconnect )