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) 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
View File
@@ -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()
}) })
+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 // 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>
) )