Feature 11: Settings home and Admin AI assistant #86
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user