Feature 11: Settings home and Admin AI assistant #86
@@ -1,11 +1,12 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
import { Route, Routes, useLocation } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
@@ -39,7 +40,8 @@ function CurrentPath() {
|
|||||||
return <p data-testid="path">{useLocation().pathname}</p>
|
return <p data-testid="path">{useLocation().pathname}</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderSettings renders the settings home on a 390 px phone (mock 1p);
|
// renderSettings renders the settings home on a 390 px phone (mock 1p) in a
|
||||||
|
// data router (the hidden sub-tabs' leave guard needs one);
|
||||||
// SettingsTabs.test.tsx covers 768 px and up.
|
// SettingsTabs.test.tsx covers 768 px and up.
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
mockViewport(390)
|
mockViewport(390)
|
||||||
@@ -47,12 +49,12 @@ function renderSettings() {
|
|||||||
return render(
|
return render(
|
||||||
<ApiProvider client={api}>
|
<ApiProvider client={api}>
|
||||||
<QueryClientProvider client={createQueryClient()}>
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
<MemoryRouter initialEntries={['/settings']}>
|
<InDataRouter path="/settings">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<CurrentPath />} />
|
<Route path="*" element={<CurrentPath />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</InDataRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ApiProvider>,
|
</ApiProvider>,
|
||||||
)
|
)
|
||||||
@@ -95,9 +97,11 @@ describe('SettingsPage', () => {
|
|||||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||||
REGULARS_PATH,
|
REGULARS_PATH,
|
||||||
'/api/v1/settings/ai',
|
'/api/v1/settings/ai',
|
||||||
|
'/api/v1/settings/ai/models',
|
||||||
CALENDAR_PATH,
|
CALENDAR_PATH,
|
||||||
'/api/v1/settings/household',
|
'/api/v1/settings/household',
|
||||||
NOTIFICATIONS_PATH,
|
NOTIFICATIONS_PATH,
|
||||||
|
'/api/v1/settings/recipe-search',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,17 @@ import styles from './SettingsPage.module.css'
|
|||||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||||
|
|
||||||
// Settings home: the list of settings pages on mobile (mock 1p), one page
|
// Settings home: the list of settings pages on mobile (mock 1p), one page
|
||||||
// with sub-tabs from 768 px (mock 2e).
|
// with sub-tabs from 768 px (mock 2e). The sub-tabs stay mounted, hidden, on
|
||||||
|
// mobile, so an unsaved draft survives the viewport crossing 768 px and its
|
||||||
|
// leave guard keeps asking before the list navigates away.
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
return useBreakpoint() === 'mobile' ? <SettingsList /> : <SettingsTabs />
|
const mobile = useBreakpoint() === 'mobile'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mobile && <SettingsList />}
|
||||||
|
<SettingsTabs hidden={mobile} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
||||||
|
|||||||
@@ -59,7 +59,8 @@
|
|||||||
color: var(--color-bg);
|
color: var(--color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A panel's display would override the hidden attribute. */
|
/* A page's or panel's display would override the hidden attribute. */
|
||||||
|
.page[hidden],
|
||||||
.single[hidden],
|
.single[hidden],
|
||||||
.columns[hidden] {
|
.columns[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
import { SettingsPage } from './SettingsPage.tsx'
|
import { SettingsPage } from './SettingsPage.tsx'
|
||||||
|
|
||||||
const MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
|
||||||
|
|
||||||
type Responder = (body: unknown) => Response
|
type Responder = (body: unknown) => Response
|
||||||
|
|
||||||
// stubAPI answers "METHOD path" from responders, each called with the
|
// stubAPI answers "METHOD path" from responders, each called with the
|
||||||
@@ -22,7 +20,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
|||||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
|
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
||||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
...responders,
|
...responders,
|
||||||
}
|
}
|
||||||
@@ -167,4 +165,29 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
|||||||
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
|
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||||
|
})
|
||||||
|
const resize = await renderAt(1180)
|
||||||
|
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
||||||
|
target: { value: 'A draft' },
|
||||||
|
})
|
||||||
|
|
||||||
|
resize(390)
|
||||||
|
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(screen.getByRole('link', { name: /^AI assistant/ }))
|
||||||
|
const confirm = await screen.findByRole('dialog', { name: 'Leave without saving?' })
|
||||||
|
fireEvent.click(within(confirm).getByRole('button', { name: 'Keep editing' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
|
||||||
|
resize(1180)
|
||||||
|
expect(screen.getByRole('textbox', { name: 'System prompt' })).toHaveValue('A draft')
|
||||||
|
const unload = new Event('beforeunload', { cancelable: true })
|
||||||
|
window.dispatchEvent(unload)
|
||||||
|
expect(unload.defaultPrevented).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
|
|||||||
// Settings at tablet and desktop widths (handoff mock 2e): one page with
|
// Settings at tablet and desktop widths (handoff mock 2e): one page with
|
||||||
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
|
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
|
||||||
// so a draft survives switching tabs, and one leave guard covers them all.
|
// so a draft survives switching tabs, and one leave guard covers them all.
|
||||||
export function SettingsTabs() {
|
// hidden hides the page but keeps it mounted, drafts and leave guard included.
|
||||||
|
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||||
const [tab, setTab] = useState<Tab>('notifications-ai')
|
const [tab, setTab] = useState<Tab>('notifications-ai')
|
||||||
const [householdDirty, setHouseholdDirty] = useState(false)
|
const [householdDirty, setHouseholdDirty] = useState(false)
|
||||||
const [aiDirty, setAIDirty] = useState(false)
|
const [aiDirty, setAIDirty] = useState(false)
|
||||||
@@ -61,7 +62,7 @@ export function SettingsTabs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page} hidden={hidden}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<div className={styles.heading}>
|
<div className={styles.heading}>
|
||||||
<h1 className={styles.title}>Settings</h1>
|
<h1 className={styles.title}>Settings</h1>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { AppRoutes } from '../App.tsx'
|
import { AppRoutes } from '../App.tsx'
|
||||||
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { stubFetchByPath } from '../test/fetchStub.ts'
|
import { stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
import styles from './BottomBar.module.css'
|
import styles from './BottomBar.module.css'
|
||||||
@@ -74,11 +74,11 @@ function renderAt(path: string, reducedMotion = false) {
|
|||||||
mockViewport(MOBILE, { reducedMotion })
|
mockViewport(MOBILE, { reducedMotion })
|
||||||
stubFetchByPath(SETTINGS_API)
|
stubFetchByPath(SETTINGS_API)
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<InDataRouter path={path}>
|
||||||
<ApiProviders>
|
<ApiProviders>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ApiProviders>
|
</ApiProviders>
|
||||||
</MemoryRouter>,
|
</InDataRouter>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export const AI_SETTINGS = {
|
|||||||
updated_at: '2026-10-01T15:00:00.000Z',
|
updated_at: '2026-10-01T15:00:00.000Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI_MODELS is a GET /api/v1/settings/ai/models body: the model AI_SETTINGS
|
||||||
|
// chooses.
|
||||||
|
export const AI_MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
||||||
|
|
||||||
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
// HOUSEHOLD_SETTINGS is a GET /api/v1/settings/household body: the seed
|
||||||
// times in New York.
|
// times in New York.
|
||||||
export const HOUSEHOLD_SETTINGS = {
|
export const HOUSEHOLD_SETTINGS = {
|
||||||
@@ -49,13 +53,15 @@ export const RECIPE_SEARCH_SETTINGS = {
|
|||||||
|
|
||||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
// today's server does: the dinners, Google Calendar and notifications status
|
// today's server does: the dinners, Google Calendar and notifications status
|
||||||
// endpoints do not exist yet.
|
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
|
||||||
|
// it also reads the AI models and recipe search settings.
|
||||||
export const SETTINGS_API = {
|
export const SETTINGS_API = {
|
||||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
|
'/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
||||||
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user