Lock settings forms while a save is pending
The AI assistant, Household and Recipe search forms stayed editable during a save, then replaced the whole draft with the server response, so an edit made while the save was in flight was lost and the leave guard reported the form as saved. Each form now sits in a fieldset that is disabled only while its save is pending (display: contents, so layout is unchanged), so no edit can be made that the response would overwrite. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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