Keep the settings sub-tabs mounted below 768 px
Crossing the 768 px breakpoint used to swap SettingsTabs for the mobile list, unmounting the AI assistant, Household and Recipe search drafts and their leave guard. SettingsPage now keeps SettingsTabs mounted at every width and hides it on mobile, so an unsaved draft survives a resize or rotation and the leave guard still asks before the list navigates away. Tests that render the mobile settings home now use a data router, which the mounted leave guard needs, and the shared settings stub answers the AI models and recipe search reads the hidden sub-tabs make. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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