Feature 11: Settings home and Admin AI assistant #86
@@ -1,11 +1,12 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
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 { 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 { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
@@ -39,7 +40,8 @@ function CurrentPath() {
|
||||
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.
|
||||
function renderSettings() {
|
||||
mockViewport(390)
|
||||
@@ -47,12 +49,12 @@ function renderSettings() {
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/settings']}>
|
||||
<InDataRouter path="/settings">
|
||||
<Routes>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<CurrentPath />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</InDataRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
@@ -95,9 +97,11 @@ describe('SettingsPage', () => {
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
REGULARS_PATH,
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/settings/ai/models',
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/household',
|
||||
NOTIFICATIONS_PATH,
|
||||
'/api/v1/settings/recipe-search',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -25,9 +25,17 @@ import styles from './SettingsPage.module.css'
|
||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||
|
||||
// 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() {
|
||||
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
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
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],
|
||||
.columns[hidden] {
|
||||
display: none;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
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 { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.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 { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { SettingsPage } from './SettingsPage.tsx'
|
||||
|
||||
const MODELS = { models: [{ id: 'claude-sonnet-4-5', display_name: 'Claude Sonnet' }] }
|
||||
|
||||
type Responder = (body: unknown) => Response
|
||||
|
||||
// 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/settings/household': () => jsonResponse(200, HOUSEHOLD_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),
|
||||
...responders,
|
||||
}
|
||||
@@ -167,4 +165,29 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
expect(screen.getByRole('tablist', { name: 'Settings sections' })).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
|
||||
// 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.
|
||||
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 [householdDirty, setHouseholdDirty] = useState(false)
|
||||
const [aiDirty, setAIDirty] = useState(false)
|
||||
@@ -61,7 +62,7 @@ export function SettingsTabs() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.page} hidden={hidden}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<h1 className={styles.title}>Settings</h1>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { AppRoutes } from '../App.tsx'
|
||||
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
@@ -74,11 +74,11 @@ function renderAt(path: string, reducedMotion = false) {
|
||||
mockViewport(MOBILE, { reducedMotion })
|
||||
stubFetchByPath(SETTINGS_API)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<InDataRouter path={path}>
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ export const AI_SETTINGS = {
|
||||
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
|
||||
// times in New York.
|
||||
export const HOUSEHOLD_SETTINGS = {
|
||||
@@ -49,13 +53,15 @@ export const RECIPE_SEARCH_SETTINGS = {
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// 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 = {
|
||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user