Feature 11: Settings home and Admin AI assistant #86

Merged
approver_agent merged 9 commits from feature/b28d9979-0f7a-4e81-bdff-2f16f3db2053-settings-admin-ai into dev 2026-10-06 12:45:06 -04:00
7 changed files with 94 additions and 7 deletions
Showing only changes of commit 021ed570fe - Show all commits
@@ -304,3 +304,9 @@
.fill .textarea {
resize: none;
}
/* A form locked while its save is pending, so no edit is made that the
saved settings would then replace. It lays out as if it were not there. */
.lock {
display: contents;
}
+47
View File
@@ -407,6 +407,30 @@ describe('AISettingsPage', () => {
expect(unload()).toBe(true)
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/ai': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(prompt(), { target: { value: 'Keep it cozy.' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [provider(), model(), slider(), prompt()]) expect(field).toBeDisabled()
expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...AI_SETTINGS, system_prompt: 'Keep it cozy.' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(prompt()).toBeEnabled()
expect(prompt()).toHaveValue('Keep it cozy.')
await waitFor(() => expect(unload()).toBe(false))
})
it('keeps 44 px tap targets', async () => {
stubAPI()
await renderPage()
@@ -680,6 +704,29 @@ describe('Recipe search card', () => {
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the card while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => new Promise<Response>((resolve) => (answer = resolve)) })
await renderCard()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(url(), { target: { value: 'https://search.example' } })
fireEvent.click(saveSearch())
expect(await within(card()).findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of [url(), searxng(), brave()]) expect(field).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }))
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
expect(url()).toBeEnabled()
expect(url()).toHaveValue('https://search.example')
await waitFor(() => expect(unload()).toBe(false))
})
it('keeps 44 px tap targets', async () => {
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
await renderCard()
+2 -2
View File
@@ -201,7 +201,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
}
return (
<>
<fieldset className={styles.lock} disabled={save.isPending}>
<div className={styles.columns}>
<Select
label="Provider"
@@ -325,7 +325,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
>
This restores MealQuest's default system prompt for everyone in the household.
</Confirm>
</>
</fieldset>
)
}
@@ -229,3 +229,9 @@
.check {
flex: none;
}
/* A form locked while its save is pending, so no edit is made that the
saved settings would then replace. It lays out as if it were not there. */
.lock {
display: contents;
}
@@ -1,5 +1,5 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, 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, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
@@ -293,6 +293,34 @@ describe('HouseholdSettingsPage', () => {
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('locks the form while a save is pending, and guards leaving until the save lands', async () => {
let answer: (response: Response) => void = () => {}
stubAPI({
'PATCH /api/v1/settings/household': () => new Promise<Response>((resolve) => (answer = resolve)),
})
await renderPage()
const unload = () => {
const event = new Event('beforeunload', { cancelable: true })
act(() => void window.dispatchEvent(event))
return event.defaultPrevented
}
fireEvent.change(input('Dinner time'), { target: { value: '18:15' } })
fireEvent.click(save())
expect(await screen.findByRole('button', { name: 'Saving…' })).toBeDisabled()
for (const field of ['Dinner time', 'Day-of reminder', 'Plan draft day', 'Google Chat Supply run time']) {
expect(input(field)).toBeDisabled()
}
expect(screen.getByRole('button', { name: 'Change' })).toBeDisabled()
expect(unload()).toBe(true)
answer(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, dinner_time: '18:15' }))
expect(await screen.findByText('Changes saved.')).toBeInTheDocument()
expect(input('Dinner time')).toBeEnabled()
expect(input('Dinner time')).toHaveValue('18:15')
await waitFor(() => expect(unload()).toBe(false))
})
it('leaves without asking when nothing changed', async () => {
stubAPI()
await renderPage()
+2 -2
View File
@@ -113,7 +113,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
}
return (
<>
<fieldset className={styles.lock} disabled={save.isPending}>
<Section title="Time zone">
<TimeZoneField
value={draft.timezone}
@@ -202,7 +202,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
{save.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</>
</fieldset>
)
}
+2 -2
View File
@@ -119,7 +119,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
}
return (
<>
<fieldset className={styles.lock} disabled={save.isPending}>
<Segmented
label="Recipe search provider"
options={providerOptions}
@@ -173,7 +173,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
{save.isPending ? 'Saving…' : 'Save recipe search'}
</Button>
</div>
</>
</fieldset>
)
}