Hold a plan_changed push until the changed plan is shown; announce reconnect
A push refused with plan_changed now stays pending until the plan has been read again, so Push and Retry stay disabled until the changed plan is on screen and nothing unreviewed can be pushed. A test holds that read back and checks that no second push is sent meanwhile, and that Retry pushes once it lands. The reconnect banner's reason and required action are now an alert, as ErrorBanner's message is, so a calendar request that turns the screen to reconnect required is announced. Task: 677261bc-1a7c-4d96-b8f9-7601caf2186d Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Response> = () => 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')
|
||||
|
||||
+6
-4
@@ -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<unknown>(path, { method: 'POST' }), false)
|
||||
},
|
||||
onSuccess: (plan) => queryClient.setQueryData<Plan>(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 })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,6 +129,12 @@
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.reconnectStatus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reconnectText {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 }) {
|
||||
<h2 id={titleId} className={styles.reconnectTitle}>
|
||||
Reconnect Google Calendar
|
||||
</h2>
|
||||
{error !== null && <p className={styles.reconnectText}>{error}</p>}
|
||||
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
|
||||
<div role="alert" className={styles.reconnectStatus}>
|
||||
{error !== null && <p className={styles.reconnectText}>{error}</p>}
|
||||
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
|
||||
</div>
|
||||
<ConnectLink icon={RefreshCw}>Reconnect</ConnectLink>
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user