Add the Google Calendar settings screen and wire Push to Calendar (Feature 16) #129

5 changed files with 61 additions and 8 deletions
Showing only changes of commit cc8a08aadc - Show all commits
+35
View File
@@ -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
View File
@@ -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()
})
+6 -3
View File
@@ -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>
)