From 021ed570fe605408b75b1345e5fcc842af1ee2af Mon Sep 17 00:00:00 2001 From: dev_agent Date: Tue, 6 Oct 2026 11:53:15 -0400 Subject: [PATCH] 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 --- web/src/settings/AISettingsPage.module.css | 6 +++ web/src/settings/AISettingsPage.test.tsx | 47 +++++++++++++++++++ web/src/settings/AISettingsPage.tsx | 4 +- .../settings/HouseholdSettingsPage.module.css | 6 +++ .../settings/HouseholdSettingsPage.test.tsx | 30 +++++++++++- web/src/settings/HouseholdSettingsPage.tsx | 4 +- web/src/settings/RecipeSearchCard.tsx | 4 +- 7 files changed, 94 insertions(+), 7 deletions(-) diff --git a/web/src/settings/AISettingsPage.module.css b/web/src/settings/AISettingsPage.module.css index 6b0924d..6ca3105 100644 --- a/web/src/settings/AISettingsPage.module.css +++ b/web/src/settings/AISettingsPage.module.css @@ -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; +} diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx index 1ad7dbd..73e68c7 100644 --- a/web/src/settings/AISettingsPage.test.tsx +++ b/web/src/settings/AISettingsPage.test.tsx @@ -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((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((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() diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx index 10fc7fc..992bc15 100644 --- a/web/src/settings/AISettingsPage.tsx +++ b/web/src/settings/AISettingsPage.tsx @@ -201,7 +201,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) { } return ( - <> +