Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #130.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
/* Settings · Google Calendar. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.shared {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.loading {
|
||||
height: 220px;
|
||||
border-radius: 26px;
|
||||
}
|
||||
|
||||
.pickerLoading {
|
||||
height: 72px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--color-neutral-300);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.text,
|
||||
.hint,
|
||||
.status,
|
||||
.error,
|
||||
.warning {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.status {
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error,
|
||||
.warning {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* "Connected · Dinners calendar": teal is the confirmation voice. */
|
||||
.connected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
/* A grant Google refused: the grape banner with Reconnect. */
|
||||
.reconnect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-accent-100);
|
||||
border: 1px solid var(--color-accent-300);
|
||||
color: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.reconnectTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.reconnectStatus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reconnectText {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* The connect endpoint is a page, so Connect and Reconnect are links drawn
|
||||
as the primary button. */
|
||||
.connect {
|
||||
align-self: flex-start;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { GOOGLE_NOT_CONNECTED } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { CalendarSettingsPage } from './CalendarSettingsPage.tsx'
|
||||
|
||||
const GOOGLE = '/api/v1/integrations/google'
|
||||
const CALENDARS = '/api/v1/integrations/google/calendars'
|
||||
const CALENDAR = '/api/v1/integrations/google/calendar'
|
||||
|
||||
// CONNECTED is a connection pushing to the household's Dinners calendar.
|
||||
const CONNECTED = {
|
||||
status: 'connected',
|
||||
calendar_id: 'dinners@group.calendar.google.com',
|
||||
calendar_name: 'Dinners',
|
||||
connected_at: '2026-10-07T15:00:00Z',
|
||||
last_sync_error: null,
|
||||
}
|
||||
|
||||
// RECONNECT is CONNECTED once Google has refused its grant.
|
||||
const RECONNECT = {
|
||||
...CONNECTED,
|
||||
status: 'reconnect_required',
|
||||
last_sync_error: 'Google access was revoked or expired — reconnect',
|
||||
}
|
||||
|
||||
// CALENDAR_LIST is the account's writable calendars, in Google's order.
|
||||
const CALENDAR_LIST = [
|
||||
{ id: 'sam@example.com', summary: 'sam@example.com', primary: true, accessRole: 'owner' },
|
||||
{ id: 'dinners@group.calendar.google.com', summary: 'Dinners', primary: false, accessRole: 'owner' },
|
||||
{ id: 'family@group.calendar.google.com', summary: 'Family', primary: false, accessRole: 'writer' },
|
||||
]
|
||||
|
||||
type Responder = (body: unknown) => Response | Promise<Response>
|
||||
|
||||
// stubAPI answers "METHOD path" from responders, each called with the
|
||||
// request's decoded JSON body. Any other request fails the test.
|
||||
function stubAPI(responders: Record<string, Responder>): Mock<typeof fetch> {
|
||||
const all: Record<string, Responder> = { 'GET /api/v1/me': () => jsonResponse(200, ME), ...responders }
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
|
||||
return Promise.resolve(respond(body))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// sent lists the requests other than GETs, as "METHOD path" and body.
|
||||
function sent(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||
.map(([input, init]) => ({
|
||||
request: `${init?.method} ${String(input)}`,
|
||||
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
// reads counts the GETs of path.
|
||||
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
send = resolve
|
||||
})
|
||||
return { response, send }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function CurrentPath() {
|
||||
return <p data-testid="path">{useLocation().pathname}</p>
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the page at /settings/calendar in a data router.
|
||||
async function renderPage() {
|
||||
const api = createApiClient(fakeLocation('/settings/calendar'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InDataRouter path="/settings/calendar">
|
||||
<Routes>
|
||||
<Route path="/settings/calendar" element={<CalendarSettingsPage />} />
|
||||
<Route path="*" element={<CurrentPath />} />
|
||||
</Routes>
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
return queryClient
|
||||
}
|
||||
|
||||
describe('CalendarSettingsPage', () => {
|
||||
it('shows the screen title and goes back to Settings', async () => {
|
||||
stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Google Calendar' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
|
||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
||||
})
|
||||
|
||||
it('shows "Connect Google Calendar" when not connected, opening the connect endpoint as a page', async () => {
|
||||
const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
await renderPage()
|
||||
|
||||
const connect = await screen.findByRole('link', { name: 'Connect Google Calendar' })
|
||||
expect(connect).toHaveAttribute('href', '/api/v1/integrations/google/connect')
|
||||
expect(declaredStyle(connect).background).toBe('var(--color-accent)')
|
||||
expect(screen.getByRole('region', { name: 'Not connected' })).toBeInTheDocument()
|
||||
// Nothing to pick yet, nor to disconnect, and Google is not asked.
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
|
||||
expect(reads(fetchMock, CALENDARS)).toBe(0)
|
||||
})
|
||||
|
||||
it('reports a status that could not load, and reads it again on Retry', async () => {
|
||||
let status = () => errorResponse(500, 'internal')
|
||||
stubAPI({ [`GET ${GOOGLE}`]: () => status() })
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent('Google Calendar status could not load: internal message')
|
||||
status = () => jsonResponse(200, GOOGLE_NOT_CONNECTED)
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refuses a status it does not know', async () => {
|
||||
stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection')
|
||||
})
|
||||
|
||||
it('shows "Connected · {calendar}" in teal-800 and the writable calendars to pick from', async () => {
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const connected = await screen.findByText('Connected · Dinners')
|
||||
expect(declaredStyle(connected).color).toBe('var(--color-accent-2-800)')
|
||||
expect(connected.querySelector('svg')).not.toBeNull()
|
||||
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
|
||||
expect(picker).toHaveValue('dinners@group.calendar.google.com')
|
||||
expect(within(picker).getAllByRole('option').map((option) => option.textContent)).toEqual([
|
||||
'sam@example.com (primary)',
|
||||
'Dinners',
|
||||
'Family',
|
||||
])
|
||||
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Connect|Reconnect/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('chooses another calendar at once, showing it saving, and reads the plans again', async () => {
|
||||
const answer = deferred()
|
||||
const fetchMock = stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
[`PUT ${CALENDAR}`]: () => answer.response,
|
||||
})
|
||||
const queryClient = await renderPage()
|
||||
queryClient.setQueryData(['plans', '2026-10-05'], null)
|
||||
|
||||
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
|
||||
fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } })
|
||||
await waitFor(() =>
|
||||
expect(sent(fetchMock)).toEqual([{ request: `PUT ${CALENDAR}`, body: { calendar_id: 'family@group.calendar.google.com' } }]),
|
||||
)
|
||||
expect(await screen.findByText('Saving…')).toBeInTheDocument()
|
||||
expect(picker).toBeDisabled()
|
||||
expect(picker).toHaveValue('family@group.calendar.google.com')
|
||||
|
||||
answer.send(jsonResponse(200, { calendar_id: 'family@group.calendar.google.com', calendar_name: 'Family' }))
|
||||
expect(await screen.findByText('Connected · Family')).toBeInTheDocument()
|
||||
expect(picker).toBeEnabled()
|
||||
expect(picker).toHaveValue('family@group.calendar.google.com')
|
||||
expect(screen.queryByText('Saving…')).not.toBeInTheDocument()
|
||||
// Choosing another calendar makes every pushed plan changed.
|
||||
expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true)
|
||||
})
|
||||
|
||||
it('says a choice that was not saved, keeping the calendar chosen before', async () => {
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
[`PUT ${CALENDAR}`]: () => errorResponse(422, 'unknown_calendar'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
|
||||
fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: unknown_calendar message')
|
||||
expect(picker).toHaveValue('dinners@group.calendar.google.com')
|
||||
expect(screen.getByText('Connected · Dinners')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks to choose a calendar when connected with none chosen', async () => {
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, { ...CONNECTED, calendar_id: null, calendar_name: null }),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Connected')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Choose the calendar to push dinners to.')).toBeInTheDocument()
|
||||
const picker = screen.getByRole('combobox', { name: 'Calendar' })
|
||||
expect(picker).toHaveValue('')
|
||||
expect(within(picker).getByRole('option', { name: 'Choose a calendar' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('says the chosen calendar can no longer be pushed to once it drops out of the list', async () => {
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST.filter((calendar) => calendar.summary !== 'Dinners')),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(
|
||||
await screen.findByText('Dinners was deleted or MealQuest can no longer add dinners to it. Choose another calendar.'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox', { name: 'Calendar' })).toHaveValue('')
|
||||
})
|
||||
|
||||
it('says when the account has no calendar MealQuest can add dinners to', async () => {
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, []),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('This Google account has no calendar MealQuest can add dinners to.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports calendars that could not load, and lists them again on Retry', async () => {
|
||||
let list = () => errorResponse(502, 'google_failed')
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => list(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent('Your calendars could not load: google_failed message')
|
||||
list = () => jsonResponse(200, CALENDAR_LIST)
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('combobox', { name: 'Calendar' })).toHaveValue('dinners@group.calendar.google.com')
|
||||
})
|
||||
|
||||
it('turns to the reconnect banner when Google refuses the grant while listing calendars', async () => {
|
||||
let status: unknown = CONNECTED
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
|
||||
[`GET ${CALENDARS}`]: () => {
|
||||
status = RECONNECT
|
||||
return errorResponse(409, 'google_reconnect_required')
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('shows a grape banner with Reconnect when reconnect is required, and offers nothing that needs Google', async () => {
|
||||
const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, RECONNECT) })
|
||||
await renderPage()
|
||||
|
||||
const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' })
|
||||
expect(declaredStyle(banner).background).toBe('var(--color-accent-100)')
|
||||
expect(declaredStyle(banner).color).toBe('var(--color-accent-800)')
|
||||
expect(banner).toHaveTextContent('Google access was revoked or expired — reconnect')
|
||||
expect(banner).toHaveTextContent('Nothing can be pushed to Calendar until you connect again.')
|
||||
expect(within(banner).getByRole('link', { name: 'Reconnect' })).toHaveAttribute(
|
||||
'href',
|
||||
'/api/v1/integrations/google/connect',
|
||||
)
|
||||
// The chosen calendar is kept, but not picked from, nor called connected.
|
||||
expect(screen.getByText('Dinners go to Dinners once Google Calendar is reconnected.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^Connected/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
expect(reads(fetchMock, CALENDARS)).toBe(0)
|
||||
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disconnects only once confirmed, then shows Connect Google Calendar', async () => {
|
||||
let status: unknown = CONNECTED
|
||||
const answer = deferred()
|
||||
const fetchMock = stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
[`DELETE ${GOOGLE}`]: () => answer.response,
|
||||
})
|
||||
const queryClient = await renderPage()
|
||||
queryClient.setQueryData(['plans', '2026-10-05'], null)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
|
||||
let dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
expect(dialog).toHaveTextContent('Dinners already pushed to Dinners stay on the calendar.')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(sent(fetchMock)).toEqual([])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
|
||||
dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
|
||||
await waitFor(() => expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }]))
|
||||
// While it disconnects the sheet stays, and cannot be dismissed.
|
||||
expect(await within(dialog).findByRole('button', { name: 'Disconnecting…' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'Keep connected' })).toBeDisabled()
|
||||
|
||||
status = GOOGLE_NOT_CONNECTED
|
||||
answer.send(new Response(null, { status: 204 }))
|
||||
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
|
||||
// Disconnecting forgets every pushed event, so pushed plans are changed.
|
||||
expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true)
|
||||
})
|
||||
|
||||
it('says a disconnect Google refused, keeping the connection', async () => {
|
||||
stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
|
||||
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
|
||||
[`DELETE ${GOOGLE}`]: () => errorResponse(502, 'revoke_failed'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Not disconnected: revoke_failed message')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('Connected · Dinners')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disconnects a connection that needs reconnecting', async () => {
|
||||
let status: unknown = RECONNECT
|
||||
const fetchMock = stubAPI({
|
||||
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
|
||||
[`DELETE ${GOOGLE}`]: () => {
|
||||
status = GOOGLE_NOT_CONNECTED
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
|
||||
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { CalendarDays, Check, ChevronLeft, RefreshCw, type LucideIcon } from 'lucide-react'
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Select } from '../ui/Select.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import {
|
||||
GOOGLE_CONNECT_PATH,
|
||||
useChooseGoogleCalendar,
|
||||
useDisconnectGoogle,
|
||||
useGoogleCalendars,
|
||||
useGoogleConnection,
|
||||
type GoogleConnection,
|
||||
} from './calendarApi.ts'
|
||||
import styles from './CalendarSettingsPage.module.css'
|
||||
import { Confirm } from './LeaveGuard.tsx'
|
||||
|
||||
// Settings · Google Calendar: connect the household's Google account, choose
|
||||
// the calendar Push to Calendar writes the week's dinners to, and
|
||||
// disconnect. A grant Google has refused shows a grape banner asking to
|
||||
// reconnect, and nothing that needs Google is offered until then.
|
||||
export function CalendarSettingsPage() {
|
||||
const connection = useGoogleConnection()
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
<p className={styles.shared}>
|
||||
Push to Calendar adds one event per dinner. It is one-way: edits made in Google Calendar do not come back.
|
||||
</p>
|
||||
{connection.isPending && <Skeleton className={styles.loading} />}
|
||||
{connection.isError && (
|
||||
<ErrorBanner onRetry={() => void connection.refetch()}>
|
||||
Google Calendar status could not load: {connection.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{connection.isSuccess && <Connection connection={connection.data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
<div>
|
||||
<p className={styles.kicker}>Settings</p>
|
||||
<h1 className={styles.title}>Google Calendar</h1>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function Connection({ connection }: { connection: GoogleConnection }) {
|
||||
switch (connection.status) {
|
||||
case 'not_connected':
|
||||
return (
|
||||
<Section title="Not connected">
|
||||
<p className={styles.text}>Connect the household's Google account to push each week's plan to one of its calendars.</p>
|
||||
<ConnectLink>Connect Google Calendar</ConnectLink>
|
||||
</Section>
|
||||
)
|
||||
case 'reconnect_required':
|
||||
return (
|
||||
<>
|
||||
<ReconnectBanner error={connection.last_sync_error} />
|
||||
<Section title="Calendar">
|
||||
<p className={styles.text}>
|
||||
{connection.calendar_name === null
|
||||
? 'No calendar chosen. Choose one once Google Calendar is reconnected.'
|
||||
: `Dinners go to ${connection.calendar_name} once Google Calendar is reconnected.`}
|
||||
</p>
|
||||
<Disconnect calendar={connection.calendar_name} />
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
case 'connected':
|
||||
return (
|
||||
<Section title="Calendar">
|
||||
<p className={styles.connected}>
|
||||
<Icon icon={Check} size="inline" />
|
||||
{connection.calendar_name === null ? 'Connected' : `Connected · ${connection.calendar_name}`}
|
||||
</p>
|
||||
<CalendarPicker connection={connection} />
|
||||
<Disconnect calendar={connection.calendar_name} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ReconnectBanner is the grape banner of a grant Google refused, with why
|
||||
// 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 (
|
||||
<section className={styles.reconnect} aria-labelledby={titleId}>
|
||||
<h2 id={titleId} className={styles.reconnectTitle}>
|
||||
Reconnect Google Calendar
|
||||
</h2>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Card className={styles.card} role="region" aria-labelledby={titleId}>
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ConnectLink opens the connect endpoint as a page: the server sends the
|
||||
// browser on to Google's consent screen, and back here once Google answers.
|
||||
function ConnectLink({ icon = CalendarDays, children }: { icon?: LucideIcon; children: ReactNode }) {
|
||||
return (
|
||||
<a href={GOOGLE_CONNECT_PATH} className={[buttonStyles.button, buttonStyles.primary, styles.connect].join(' ')}>
|
||||
<Icon icon={icon} size="inline" />
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// CalendarPicker chooses, from the calendars the account can write to, the
|
||||
// one dinners are pushed to; a choice is saved at once. A calendar chosen
|
||||
// before that has since been deleted or become read-only is no longer
|
||||
// listed, and is said to be so.
|
||||
function CalendarPicker({ connection }: { connection: GoogleConnection }) {
|
||||
const calendars = useGoogleCalendars(true)
|
||||
const choose = useChooseGoogleCalendar()
|
||||
|
||||
if (calendars.isPending) return <Skeleton className={styles.pickerLoading} />
|
||||
if (calendars.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void calendars.refetch()}>
|
||||
Your calendars could not load: {calendars.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (calendars.data.length === 0) {
|
||||
return <p className={styles.warning}>This Google account has no calendar MealQuest can add dinners to.</p>
|
||||
}
|
||||
|
||||
const listed = calendars.data.some((calendar) => calendar.id === connection.calendar_id)
|
||||
const value = choose.isPending ? choose.variables : listed ? connection.calendar_id! : ''
|
||||
return (
|
||||
<>
|
||||
{connection.calendar_id === null && <p className={styles.text}>Choose the calendar to push dinners to.</p>}
|
||||
{connection.calendar_id !== null && !listed && (
|
||||
<p className={styles.warning}>
|
||||
{connection.calendar_name} was deleted or MealQuest can no longer add dinners to it. Choose another calendar.
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
label="Calendar"
|
||||
value={value}
|
||||
disabled={choose.isPending}
|
||||
onChange={(event) => {
|
||||
choose.reset()
|
||||
choose.mutate(event.target.value)
|
||||
}}
|
||||
>
|
||||
{value === '' && (
|
||||
<option value="" disabled>
|
||||
Choose a calendar
|
||||
</option>
|
||||
)}
|
||||
{calendars.data.map((calendar) => (
|
||||
<option key={calendar.id} value={calendar.id}>
|
||||
{calendar.primary ? `${calendar.summary} (primary)` : calendar.summary}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<p className={styles.status} role="status">
|
||||
{choose.isPending && 'Saving…'}
|
||||
</p>
|
||||
{choose.isError && (
|
||||
<p className={styles.error} role="alert">
|
||||
Not saved: {choose.error.message}
|
||||
</p>
|
||||
)}
|
||||
<p className={styles.hint}>
|
||||
After choosing another calendar, the next push writes the whole week there. Dinners already pushed stay where they
|
||||
are.
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Disconnect revokes MealQuest's access at Google, after asking.
|
||||
function Disconnect({ calendar }: { calendar: string | null }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const disconnect = useDisconnectGoogle()
|
||||
return (
|
||||
<>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
disconnect.reset()
|
||||
setConfirming(true)
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
<Confirm
|
||||
open={confirming}
|
||||
title="Disconnect Google Calendar?"
|
||||
confirmLabel={disconnect.isPending ? 'Disconnecting…' : 'Disconnect'}
|
||||
cancelLabel="Keep connected"
|
||||
busy={disconnect.isPending}
|
||||
dismissible={!disconnect.isPending}
|
||||
error={disconnect.isError ? `Not disconnected: ${disconnect.error.message}` : null}
|
||||
onCancel={() => setConfirming(false)}
|
||||
onConfirm={() => disconnect.mutate(undefined, { onSuccess: () => setConfirming(false) })}
|
||||
>
|
||||
MealQuest gives up its access to your Google account and stops pushing dinners.{' '}
|
||||
{calendar === null ? 'Dinners already pushed' : `Dinners already pushed to ${calendar}`} stay on the calendar.
|
||||
</Confirm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Route, Routes, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
@@ -22,7 +22,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
||||
const CALENDAR_PATH = '/api/v1/settings/calendar'
|
||||
const CALENDAR_PATH = '/api/v1/integrations/google'
|
||||
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
||||
|
||||
// stubSettings answers as today's server does (see SETTINGS_API), with
|
||||
@@ -96,9 +96,9 @@ describe('SettingsPage', () => {
|
||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
REGULARS_PATH,
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/settings/ai/models',
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/household',
|
||||
NOTIFICATIONS_PATH,
|
||||
'/api/v1/settings/recipe-search',
|
||||
@@ -109,7 +109,6 @@ describe('SettingsPage', () => {
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
|
||||
expect(
|
||||
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
|
||||
).toBeInTheDocument()
|
||||
@@ -119,7 +118,8 @@ describe('SettingsPage', () => {
|
||||
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
|
||||
stubSettings({
|
||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
||||
[CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }),
|
||||
[CALENDAR_PATH]: () =>
|
||||
jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }),
|
||||
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
|
||||
})
|
||||
renderSettings()
|
||||
@@ -133,7 +133,7 @@ describe('SettingsPage', () => {
|
||||
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
|
||||
stubSettings({
|
||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
||||
[CALENDAR_PATH]: () => jsonResponse(200, { connected: false }),
|
||||
[CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
|
||||
})
|
||||
renderSettings()
|
||||
@@ -144,6 +144,15 @@ describe('SettingsPage', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['connected with no calendar chosen', { status: 'connected' }, 'Connected · no calendar chosen'],
|
||||
['reconnect required', { status: 'reconnect_required', calendar_id: 'c1', calendar_name: 'Dinners' }, 'Reconnect needed'],
|
||||
])('says Google Calendar is %s', async (_, connection, said) => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, ...connection }) })
|
||||
renderSettings()
|
||||
expect(await within(row('Google Calendar')).findByText(said)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a status endpoint failure other than a missing endpoint', async () => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
|
||||
renderSettings()
|
||||
@@ -151,10 +160,10 @@ describe('SettingsPage', () => {
|
||||
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
|
||||
})
|
||||
|
||||
it('refuses a connected calendar without a name', async () => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
|
||||
it('refuses a Google Calendar status it does not know', async () => {
|
||||
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) })
|
||||
renderSettings()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection')
|
||||
})
|
||||
|
||||
it('shows another provider and model as the settings name them', async () => {
|
||||
|
||||
@@ -11,16 +11,15 @@ import { Tag } from '../ui/Tag.tsx'
|
||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||
import {
|
||||
aiStatus,
|
||||
calendarStatus,
|
||||
formatClockTime,
|
||||
notificationsStatus,
|
||||
regularDinnersStatus,
|
||||
useAISettings,
|
||||
useCalendarStatus,
|
||||
useHouseholdSettings,
|
||||
useNotificationsStatus,
|
||||
useRegularDinnersCount,
|
||||
} from './api.ts'
|
||||
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
|
||||
import styles from './SettingsPage.module.css'
|
||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||
|
||||
@@ -39,14 +38,14 @@ export function SettingsPage() {
|
||||
}
|
||||
|
||||
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
||||
// page and shows a live status line. Regular dinners, Google Calendar and
|
||||
// Notifications & Google Chat read endpoints their features have not served
|
||||
// yet; until then each shows its not-yet-available state (see api.ts).
|
||||
// page and shows a live status line. Regular dinners and Notifications &
|
||||
// Google Chat read endpoints their features have not served yet; until then
|
||||
// each shows its not-yet-available state (see api.ts).
|
||||
function SettingsList() {
|
||||
const household = useHouseholdSettings()
|
||||
const ai = useAISettings()
|
||||
const regulars = useRegularDinnersCount()
|
||||
const calendar = useCalendarStatus()
|
||||
const calendar = useGoogleConnection()
|
||||
const notifications = useNotificationsStatus()
|
||||
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
@@ -154,7 +154,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const resize = await renderAt(767)
|
||||
@@ -169,7 +169,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const resize = await renderAt(1180)
|
||||
|
||||
@@ -14,9 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
|
||||
// dinners read API. Until it lands the server answers 404 and the Regular
|
||||
// dinners row says the count is unavailable.
|
||||
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
|
||||
// TODO(Feature 16): the Google Calendar integration status. Until Feature 16
|
||||
// serves it the server answers 404, which is the truth: not connected.
|
||||
export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
|
||||
// TODO(notifications feature): the Web Push and Google Chat status. Until it
|
||||
// is served the server answers 404, which is the truth: push is off and no
|
||||
// Google Chat space is connected.
|
||||
@@ -516,30 +513,6 @@ export function regularDinnersStatus(count: number | null): string {
|
||||
return `${count} on rotation`
|
||||
}
|
||||
|
||||
// CalendarStatus is the Google Calendar integration status; calendar_name
|
||||
// is the connected calendar's name.
|
||||
export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string }
|
||||
|
||||
export function useCalendarStatus() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['settings', 'calendar'],
|
||||
queryFn: () =>
|
||||
fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => {
|
||||
if (isRecord(body) && body.connected === false) return { connected: false }
|
||||
if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') {
|
||||
return { connected: true, calendar_name: body.calendar_name }
|
||||
}
|
||||
throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function calendarStatus(status: CalendarStatus | null): string {
|
||||
if (status === null || !status.connected) return 'Not connected'
|
||||
return `Connected · ${status.calendar_name}`
|
||||
}
|
||||
|
||||
// NotificationsStatus is whether Web Push is on and the connected Google
|
||||
// Chat space's name, null when none is.
|
||||
export type NotificationsStatus = { push: boolean; chat_space: string | null }
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
export const GOOGLE_PATH = '/api/v1/integrations/google'
|
||||
// GOOGLE_CONNECT_PATH starts connecting: the browser opens it as a page,
|
||||
// and the server sends it on to Google's consent screen and, once Google
|
||||
// answers, back to /settings/calendar.
|
||||
export const GOOGLE_CONNECT_PATH = `${GOOGLE_PATH}/connect`
|
||||
export const GOOGLE_CALENDARS_PATH = `${GOOGLE_PATH}/calendars`
|
||||
export const GOOGLE_CALENDAR_PATH = `${GOOGLE_PATH}/calendar`
|
||||
|
||||
// GOOGLE_RECONNECT_REQUIRED is the API error code of a call Google refused
|
||||
// the stored grant for: only connecting again helps.
|
||||
export const GOOGLE_RECONNECT_REQUIRED = 'google_reconnect_required'
|
||||
|
||||
export const GOOGLE_STATUSES = ['not_connected', 'connected', 'reconnect_required'] as const
|
||||
|
||||
// GoogleStatus is reconnect_required once Google has refused the stored
|
||||
// grant: the connection and its chosen calendar are kept, but nothing can be
|
||||
// pushed until the household connects again.
|
||||
export type GoogleStatus = (typeof GOOGLE_STATUSES)[number]
|
||||
|
||||
// GoogleConnection is GET /api/v1/integrations/google: the connection's
|
||||
// status, the calendar chosen to push dinners to, when it was connected and
|
||||
// why it needs a reconnect, each null when there is none.
|
||||
export type GoogleConnection = {
|
||||
status: GoogleStatus
|
||||
calendar_id: string | null
|
||||
calendar_name: string | null
|
||||
connected_at: string | null
|
||||
last_sync_error: string | null
|
||||
}
|
||||
|
||||
// GoogleCalendar is a calendar MealQuest can push dinners to: one the
|
||||
// connected account can write to.
|
||||
export type GoogleCalendar = { id: string; summary: string; primary: boolean }
|
||||
|
||||
export const GOOGLE_KEY = ['integrations', 'google'] as const
|
||||
const GOOGLE_CALENDARS_KEY = ['integrations', 'google', 'calendars'] as const
|
||||
|
||||
function isStringOrNull(value: unknown): value is string | null {
|
||||
return value === null || typeof value === 'string'
|
||||
}
|
||||
|
||||
function parseConnection(where: string, body: unknown): GoogleConnection {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
!GOOGLE_STATUSES.includes(body.status as GoogleStatus) ||
|
||||
!isStringOrNull(body.calendar_id) ||
|
||||
!isStringOrNull(body.calendar_name) ||
|
||||
!isStringOrNull(body.connected_at) ||
|
||||
!isStringOrNull(body.last_sync_error)
|
||||
) {
|
||||
throw new Error(`${where}: response is not a Google Calendar connection`)
|
||||
}
|
||||
return {
|
||||
status: body.status as GoogleStatus,
|
||||
calendar_id: body.calendar_id,
|
||||
calendar_name: body.calendar_name,
|
||||
connected_at: body.connected_at,
|
||||
last_sync_error: body.last_sync_error,
|
||||
}
|
||||
}
|
||||
|
||||
// reconnectRequired says whether error is Google refusing the stored grant.
|
||||
export function reconnectRequired(error: Error): boolean {
|
||||
return error instanceof ApiError && error.code === GOOGLE_RECONNECT_REQUIRED
|
||||
}
|
||||
|
||||
// noteGoogleFailure reads the connection again after a call Google refused
|
||||
// the grant for: the server now reports it as reconnect_required.
|
||||
export function noteGoogleFailure(queryClient: QueryClient, error: Error) {
|
||||
if (reconnectRequired(error)) void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true })
|
||||
}
|
||||
|
||||
// useGoogleConnection is the Google Calendar connection.
|
||||
export function useGoogleConnection() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: GOOGLE_KEY,
|
||||
queryFn: async () => parseConnection(`GET ${GOOGLE_PATH}`, await apiFetch<unknown>(GOOGLE_PATH)),
|
||||
})
|
||||
}
|
||||
|
||||
// useGoogleCalendars lists the connected account's calendars MealQuest can
|
||||
// push to, in Google's order. It asks Google, so it is read only while
|
||||
// connected.
|
||||
export function useGoogleCalendars(enabled: boolean) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: GOOGLE_CALENDARS_KEY,
|
||||
enabled,
|
||||
queryFn: async (): Promise<GoogleCalendar[]> => {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await apiFetch<unknown>(GOOGLE_CALENDARS_PATH)
|
||||
} catch (error) {
|
||||
noteGoogleFailure(queryClient, error as Error)
|
||||
throw error
|
||||
}
|
||||
if (
|
||||
!Array.isArray(body) ||
|
||||
!body.every((c) => isRecord(c) && typeof c.id === 'string' && typeof c.summary === 'string' && typeof c.primary === 'boolean')
|
||||
) {
|
||||
throw new Error(`GET ${GOOGLE_CALENDARS_PATH}: response is not a calendar list`)
|
||||
}
|
||||
return body.map((c: Record<string, unknown>) => ({ id: c.id as string, summary: c.summary as string, primary: c.primary as boolean }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useChooseGoogleCalendar chooses the calendar dinners are pushed to.
|
||||
// Choosing another calendar than the one pushed to makes every pushed plan
|
||||
// changed (the next push writes the whole week to the new calendar), so the
|
||||
// plans are read again.
|
||||
export function useChooseGoogleCalendar() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (calendarId: string) => {
|
||||
const where = `PUT ${GOOGLE_CALENDAR_PATH}`
|
||||
const body = await apiFetch<unknown>(GOOGLE_CALENDAR_PATH, { method: 'PUT', body: { calendar_id: calendarId } })
|
||||
if (!isRecord(body) || body.calendar_id !== calendarId || typeof body.calendar_name !== 'string') {
|
||||
throw new Error(`${where}: response is not the calendar chosen`)
|
||||
}
|
||||
return { calendar_id: calendarId, calendar_name: body.calendar_name }
|
||||
},
|
||||
onSuccess: (chosen) => {
|
||||
queryClient.setQueryData<GoogleConnection>(GOOGLE_KEY, (connection) => connection && { ...connection, ...chosen })
|
||||
void queryClient.invalidateQueries({ queryKey: ['plans'] })
|
||||
},
|
||||
onError: (error) => noteGoogleFailure(queryClient, error),
|
||||
})
|
||||
}
|
||||
|
||||
// useDisconnectGoogle revokes MealQuest's access at Google and forgets the
|
||||
// connection and every pushed event, making each pushed plan changed: the
|
||||
// connection and the plans are read again.
|
||||
export function useDisconnectGoogle() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => apiFetch<void>(GOOGLE_PATH, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.removeQueries({ queryKey: GOOGLE_CALENDARS_KEY })
|
||||
void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true })
|
||||
void queryClient.invalidateQueries({ queryKey: ['plans'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// calendarStatus is the settings list's line for the connection: the
|
||||
// handoff's "Connected · Dinners calendar".
|
||||
export function calendarStatus(connection: GoogleConnection): string {
|
||||
switch (connection.status) {
|
||||
case 'not_connected':
|
||||
return 'Not connected'
|
||||
case 'reconnect_required':
|
||||
return 'Reconnect needed'
|
||||
case 'connected':
|
||||
return connection.calendar_name === null ? 'Connected · no calendar chosen' : `Connected · ${connection.calendar_name}`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user