Add the Google Calendar settings screen and wire Push to Calendar (Feature 16) #129
@@ -392,6 +392,41 @@ describe('PlanPage', () => {
|
|||||||
expect(planReads(fetchMock)).toBe(2)
|
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 () => {
|
it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => {
|
||||||
let plan = planBody('draft')
|
let plan = planBody('draft')
|
||||||
let job = draftJob('running')
|
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
|
// 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
|
// 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
|
// 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
|
// reads the plan again, and stays pending until that read lands: nothing is
|
||||||
// Calendar connection again, which is now reconnect required.
|
// 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) {
|
export function usePushToCalendar(weekStart: string) {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -292,9 +294,9 @@ export function usePushToCalendar(weekStart: string) {
|
|||||||
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
|
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
|
||||||
},
|
},
|
||||||
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
|
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
|
||||||
onError: (error) => {
|
onError: async (error) => {
|
||||||
if (error instanceof ApiError && error.code === 'plan_changed') void queryClient.invalidateQueries({ queryKey: key })
|
|
||||||
noteGoogleFailure(queryClient, error)
|
noteGoogleFailure(queryClient, error)
|
||||||
|
if (error instanceof ApiError && error.code === 'plan_changed') await queryClient.invalidateQueries({ queryKey: key })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reconnectStatus {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.reconnectText {
|
.reconnectText {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
|
|||||||
@@ -279,7 +279,14 @@ describe('CalendarSettingsPage', () => {
|
|||||||
})
|
})
|
||||||
await renderPage()
|
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.queryByRole('combobox')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByText('Connected · Dinners')).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
|
// 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 }) {
|
function ReconnectBanner({ error }: { error: string | null }) {
|
||||||
const titleId = useId()
|
const titleId = useId()
|
||||||
return (
|
return (
|
||||||
@@ -104,8 +105,10 @@ function ReconnectBanner({ error }: { error: string | null }) {
|
|||||||
<h2 id={titleId} className={styles.reconnectTitle}>
|
<h2 id={titleId} className={styles.reconnectTitle}>
|
||||||
Reconnect Google Calendar
|
Reconnect Google Calendar
|
||||||
</h2>
|
</h2>
|
||||||
{error !== null && <p className={styles.reconnectText}>{error}</p>}
|
<div role="alert" className={styles.reconnectStatus}>
|
||||||
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
|
{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>
|
<ConnectLink icon={RefreshCw}>Reconnect</ConnectLink>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user