Pre-fill the SearXNG URL from the deployment default
The service now reads the optional MEALQUEST_SEARXNG_URL, validates it at startup like any SearXNG URL, and returns it in the recipe search settings as the read-only default_base_url. base_url stays the saved URL only, and PATCH is unchanged: nothing is stored until the household saves. The recipe search card pre-fills the SearXNG URL from default_base_url while no URL is saved, and an edit now clears the last Test result so a stale count or error never sits beside "Save your changes to test them." Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -462,16 +462,52 @@ describe('Recipe search card', () => {
|
||||
expect(testSearch()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('starts on SearXNG with an empty URL before recipe search is set up', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () =>
|
||||
jsonResponse(200, { provider: null, base_url: null, api_key_set: false, api_key_last4: null, updated_at: null }),
|
||||
const UNCONFIGURED = {
|
||||
provider: null,
|
||||
base_url: null,
|
||||
default_base_url: 'http://searxng:8080',
|
||||
api_key_set: false,
|
||||
api_key_last4: null,
|
||||
updated_at: null,
|
||||
}
|
||||
|
||||
it('starts on SearXNG with the deployment\'s URL before recipe search is set up, and saves it', async () => {
|
||||
const fetchMock = stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, UNCONFIGURED),
|
||||
'PATCH /api/v1/settings/recipe-search': (body) =>
|
||||
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }),
|
||||
})
|
||||
await renderCard()
|
||||
expect(searxng()).toHaveAttribute('aria-checked', 'true')
|
||||
expect(url()).toHaveValue('')
|
||||
expect(url()).toHaveValue('http://searxng:8080')
|
||||
expect(testSearch()).toBeDisabled()
|
||||
expect(saveSearch()).toBeEnabled()
|
||||
fireEvent.click(saveSearch())
|
||||
expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument()
|
||||
expect(sent(fetchMock)).toEqual([
|
||||
{
|
||||
request: 'PATCH /api/v1/settings/recipe-search',
|
||||
body: { provider: 'searxng', base_url: 'http://searxng:8080' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('starts with an empty URL when the deployment names no SearXNG', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, { ...UNCONFIGURED, default_base_url: null }),
|
||||
})
|
||||
await renderCard()
|
||||
expect(url()).toHaveValue('')
|
||||
})
|
||||
|
||||
it('shows the saved URL, not the deployment\'s', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/settings/recipe-search': () =>
|
||||
jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, base_url: 'https://search.example' }),
|
||||
})
|
||||
await renderCard()
|
||||
expect(url()).toHaveValue('https://search.example')
|
||||
expect(saveSearch()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('switches providers, keeping each provider\'s field', async () => {
|
||||
@@ -587,6 +623,32 @@ describe('Recipe search card', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('clears a test result when the settings are edited', async () => {
|
||||
stubAPI({
|
||||
'POST /api/v1/settings/recipe-search/test': () =>
|
||||
jsonResponse(200, { query: 'chicken tikka masala', count: 12 }),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.click(testSearch())
|
||||
expect(await within(card()).findByText("Found 12 recipes for 'chicken tikka masala'")).toBeInTheDocument()
|
||||
fireEvent.change(url(), { target: { value: 'https://search.example' } })
|
||||
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
|
||||
expect(within(card()).queryByText(/Found 12 recipes/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears a test error when the settings are edited', async () => {
|
||||
stubAPI({
|
||||
'POST /api/v1/settings/recipe-search/test': () =>
|
||||
apiError(502, 'search_failed', 'recipes: SearXNG answered 403 Forbidden'),
|
||||
})
|
||||
await renderCard()
|
||||
fireEvent.click(testSearch())
|
||||
expect(await within(card()).findByRole('alert')).toHaveTextContent('Test failed')
|
||||
fireEvent.click(brave())
|
||||
expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument()
|
||||
expect(within(card()).queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('asks before leaving with an unsaved recipe search change', async () => {
|
||||
stubAPI()
|
||||
await renderCard()
|
||||
|
||||
@@ -61,9 +61,14 @@ type Draft = {
|
||||
}
|
||||
|
||||
// draftOf starts a household that has not set up recipe search on SearXNG,
|
||||
// the sidecar every deployment runs.
|
||||
// the sidecar every deployment runs. With no URL saved the field holds the
|
||||
// deployment's SearXNG URL, which saving stores.
|
||||
function draftOf(saved: RecipeSearchSettings): Draft {
|
||||
return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? '', api_key: null }
|
||||
return {
|
||||
provider: saved.provider ?? 'searxng',
|
||||
base_url: saved.base_url ?? saved.default_base_url ?? '',
|
||||
api_key: null,
|
||||
}
|
||||
}
|
||||
|
||||
// patchOf is the PATCH body for what draft changes. Only the chosen
|
||||
@@ -88,8 +93,11 @@ function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSetting
|
||||
}, [dirty, onDirtyChange])
|
||||
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||
|
||||
// An edit clears the last save and test results: they describe the saved
|
||||
// settings, not the draft.
|
||||
function edit(change: Partial<Draft>) {
|
||||
save.reset()
|
||||
test.reset()
|
||||
setDraft((current) => ({ ...current, ...change }))
|
||||
}
|
||||
|
||||
|
||||
@@ -209,8 +209,12 @@ export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels
|
||||
export type RecipeSearchSettings = {
|
||||
// Null until recipe search is first set up.
|
||||
provider: RecipeSearchProvider | null
|
||||
// The SearXNG instance's URL, null when none is set.
|
||||
// The saved SearXNG instance's URL, null when none is set.
|
||||
base_url: string | null
|
||||
// The deployment's SearXNG URL (MEALQUEST_SEARXNG_URL) to suggest while
|
||||
// base_url is null; null when the deployment names none. Only saving
|
||||
// base_url stores a URL.
|
||||
default_base_url: string | null
|
||||
api_key_set: boolean
|
||||
api_key_last4: string | null
|
||||
updated_at: string | null
|
||||
@@ -237,6 +241,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe
|
||||
!isRecord(body) ||
|
||||
!isStringOrNull(body.provider) ||
|
||||
!isStringOrNull(body.base_url) ||
|
||||
!isStringOrNull(body.default_base_url) ||
|
||||
typeof body.api_key_set !== 'boolean' ||
|
||||
!isStringOrNull(body.api_key_last4) ||
|
||||
!isStringOrNull(body.updated_at)
|
||||
@@ -249,6 +254,7 @@ function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSe
|
||||
return {
|
||||
provider: body.provider,
|
||||
base_url: body.base_url,
|
||||
default_base_url: body.default_base_url,
|
||||
api_key_set: body.api_key_set,
|
||||
api_key_last4: body.api_key_last4,
|
||||
updated_at: body.updated_at,
|
||||
|
||||
@@ -21,6 +21,7 @@ export const AI_SETTINGS = {
|
||||
export const RECIPE_SEARCH_SETTINGS = {
|
||||
provider: 'searxng',
|
||||
base_url: 'http://searxng:8080',
|
||||
default_base_url: 'http://searxng:8080',
|
||||
api_key_set: false,
|
||||
api_key_last4: null,
|
||||
updated_at: '2026-10-01T15:00:00.000Z',
|
||||
|
||||
Reference in New Issue
Block a user