Feature 11: Settings home and Admin AI assistant #86
@@ -304,3 +304,9 @@
|
|||||||
.fill .textarea {
|
.fill .textarea {
|
||||||
resize: none;
|
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)
|
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 () => {
|
it('keeps 44 px tap targets', async () => {
|
||||||
stubAPI()
|
stubAPI()
|
||||||
await renderPage()
|
await renderPage()
|
||||||
@@ -680,6 +704,29 @@ describe('Recipe search card', () => {
|
|||||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
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 () => {
|
it('keeps 44 px tap targets', async () => {
|
||||||
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
|
stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) })
|
||||||
await renderCard()
|
await renderCard()
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<fieldset className={styles.lock} disabled={save.isPending}>
|
||||||
<div className={styles.columns}>
|
<div className={styles.columns}>
|
||||||
<Select
|
<Select
|
||||||
label="Provider"
|
label="Provider"
|
||||||
@@ -325,7 +325,7 @@ function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) {
|
|||||||
>
|
>
|
||||||
This restores MealQuest's default system prompt for everyone in the household.
|
This restores MealQuest's default system prompt for everyone in the household.
|
||||||
</Confirm>
|
</Confirm>
|
||||||
</>
|
</fieldset>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -229,3 +229,9 @@
|
|||||||
.check {
|
.check {
|
||||||
flex: none;
|
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 { 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 { Route, Routes, useLocation } from 'react-router'
|
||||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, ApiError, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
@@ -293,6 +293,34 @@ describe('HouseholdSettingsPage', () => {
|
|||||||
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
|
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 () => {
|
it('leaves without asking when nothing changed', async () => {
|
||||||
stubAPI()
|
stubAPI()
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<fieldset className={styles.lock} disabled={save.isPending}>
|
||||||
<Section title="Time zone">
|
<Section title="Time zone">
|
||||||
<TimeZoneField
|
<TimeZoneField
|
||||||
value={draft.timezone}
|
value={draft.timezone}
|
||||||
@@ -202,7 +202,7 @@ function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormPr
|
|||||||
{save.isPending ? 'Saving…' : 'Save changes'}
|
{save.isPending ? 'Saving…' : 'Save changes'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</fieldset>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<fieldset className={styles.lock} disabled={save.isPending}>
|
||||||
<Segmented
|
<Segmented
|
||||||
label="Recipe search provider"
|
label="Recipe search provider"
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
@@ -173,7 +173,7 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
|
|||||||
{save.isPending ? 'Saving…' : 'Save recipe search'}
|
{save.isPending ? 'Saving…' : 'Save recipe search'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</fieldset>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user