diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index f144369..77ff5db 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts' import { mockViewport } from './test/matchMedia.ts' import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts' import App, { routes } from './App.tsx' -import { AI_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' import { InDataRouter } from './test/dataRouter.tsx' afterEach(() => { @@ -80,6 +80,7 @@ describe('App routes', () => { '/api/v1/me': () => jsonResponse(200, ME), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), '/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }), + '/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), }) renderAt('/settings/ai') const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' }) diff --git a/web/src/settings/AISettingsPage.module.css b/web/src/settings/AISettingsPage.module.css index a004dbe..9c477dc 100644 --- a/web/src/settings/AISettingsPage.module.css +++ b/web/src/settings/AISettingsPage.module.css @@ -251,3 +251,33 @@ justify-content: flex-end; gap: 10px; } + +/* Recipe search card, below the AI assistant's actions. */ +.card { + display: flex; + flex-direction: column; + gap: 14px; + border: 1px solid var(--color-neutral-300); +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.cardLoading { + height: 160px; + border-radius: var(--radius-lg); +} + +.cardActions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: 10px; +} + +.cardActions > button { + min-height: 52px; +} diff --git a/web/src/settings/AISettingsPage.test.tsx b/web/src/settings/AISettingsPage.test.tsx index 4ddfc9f..af9ed55 100644 --- a/web/src/settings/AISettingsPage.test.tsx +++ b/web/src/settings/AISettingsPage.test.tsx @@ -4,11 +4,12 @@ import { Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_SETTINGS } from '../test/ApiProviders.tsx' +import { AI_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' -import { AISettingsPage, formatVerifiedDate, maskedKey } from './AISettingsPage.tsx' +import { AISettingsPage, formatVerifiedDate } from './AISettingsPage.tsx' +import { maskedKey } from './api.ts' const MODELS = { models: [ @@ -30,6 +31,7 @@ function stubAPI(responders: Record = {}): Mock 'GET /api/v1/me': () => jsonResponse(200, ME), 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), 'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS), + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), ...responders, } const fetchMock = vi.fn((input, init) => { @@ -422,6 +424,192 @@ describe('AISettingsPage', () => { }) }) +// apiError is an API error response carrying message. +function apiError(status: number, code: string, message: string): Response { + return jsonResponse(status, { error: { code, message, retryable: false } }) +} + +const BRAVE_SETTINGS = { ...RECIPE_SEARCH_SETTINGS, provider: 'brave', api_key_set: true, api_key_last4: 'b7c2' } + +describe('Recipe search card', () => { + const card = () => screen.getByRole('region', { name: 'Recipe search' }) + const searxng = () => within(card()).getByRole('radio', { name: 'SearXNG' }) + const brave = () => within(card()).getByRole('radio', { name: 'Brave' }) + const url = () => within(card()).getByRole('textbox', { name: 'SearXNG URL' }) + const saveSearch = () => within(card()).getByRole('button', { name: 'Save recipe search' }) + const testSearch = () => within(card()).getByRole('button', { name: 'Test' }) + + async function renderCard() { + await renderPage() + await within(card()).findByRole('radiogroup', { name: 'Recipe search provider' }) + } + + // addKey enters key in the Brave key sheet. + function addKey(button: string, key: string) { + fireEvent.click(within(card()).getByRole('button', { name: button })) + const sheet = screen.getByRole('dialog') + fireEvent.change(within(sheet).getByLabelText('Brave API key'), { target: { value: key } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Use this key' })) + } + + it('shows the saved SearXNG URL', async () => { + stubAPI() + await renderCard() + expect(searxng()).toHaveAttribute('aria-checked', 'true') + expect(url()).toHaveValue('http://searxng:8080') + expect(within(card()).queryByText('Brave API key')).not.toBeInTheDocument() + expect(saveSearch()).toBeDisabled() + 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 }), + }) + await renderCard() + expect(searxng()).toHaveAttribute('aria-checked', 'true') + expect(url()).toHaveValue('') + expect(testSearch()).toBeDisabled() + expect(saveSearch()).toBeEnabled() + }) + + it('switches providers, keeping each provider\'s field', async () => { + stubAPI() + await renderCard() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + fireEvent.click(brave()) + expect(brave()).toHaveAttribute('aria-checked', 'true') + expect(within(card()).queryByRole('textbox', { name: 'SearXNG URL' })).not.toBeInTheDocument() + expect(within(card()).getByText('No key stored')).toBeInTheDocument() + expect(within(card()).getByRole('button', { name: 'Add key' })).toBeInTheDocument() + + fireEvent.click(searxng()) + expect(url()).toHaveValue('https://search.example') + }) + + it('saves a new SearXNG URL', async () => { + const fetchMock = stubAPI({ + 'PATCH /api/v1/settings/recipe-search': (body) => + jsonResponse(200, { ...RECIPE_SEARCH_SETTINGS, ...(body as object) }), + }) + await renderCard() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + expect(testSearch()).toBeDisabled() + expect(within(card()).getByText('Save your changes to test them.')).toBeInTheDocument() + fireEvent.click(saveSearch()) + expect(await within(card()).findByText('Recipe search saved.')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PATCH /api/v1/settings/recipe-search', body: { base_url: 'https://search.example' } }, + ]) + expect(saveSearch()).toBeDisabled() + expect(testSearch()).toBeEnabled() + }) + + it('saves a switch to Brave with its key, then shows only the key\'s last four characters', async () => { + const fetchMock = stubAPI({ 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) + await renderCard() + fireEvent.click(brave()) + addKey('Add key', 'BSA-secret-key-b7c2') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(within(card()).getByText('New key not saved yet')).toBeInTheDocument() + expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2') + + 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: 'brave', api_key: 'BSA-secret-key-b7c2' } }, + ]) + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(within(card()).queryByText('New key not saved yet')).not.toBeInTheDocument() + expect(within(card()).getByRole('button', { name: 'Replace' })).toBeInTheDocument() + expect(document.body).not.toHaveTextContent('BSA-secret-key-b7c2') + }) + + it('masks a stored Brave key and replaces it', async () => { + const fetchMock = stubAPI({ + 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS), + 'PATCH /api/v1/settings/recipe-search': () => jsonResponse(200, { ...BRAVE_SETTINGS, api_key_last4: '9d0e' }), + }) + await renderCard() + expect(brave()).toHaveAttribute('aria-checked', 'true') + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(saveSearch()).toBeDisabled() + + addKey('Replace', 'BSA-other-key-9d0e') + fireEvent.click(within(card()).getByRole('button', { name: 'Discard' })) + expect(within(card()).getByText('••••••••b7c2')).toBeInTheDocument() + expect(saveSearch()).toBeDisabled() + + addKey('Replace', 'BSA-other-key-9d0e') + fireEvent.click(saveSearch()) + expect(await within(card()).findByText('••••••••9d0e')).toBeInTheDocument() + expect(sent(fetchMock)).toEqual([ + { request: 'PATCH /api/v1/settings/recipe-search', body: { api_key: 'BSA-other-key-9d0e' } }, + ]) + }) + + it('reports a save the server refuses', async () => { + stubAPI({ + 'PATCH /api/v1/settings/recipe-search': () => + apiError(400, 'invalid_settings', 'invalid recipe search settings: api_key: must be at least 8 characters'), + }) + await renderCard() + fireEvent.click(brave()) + addKey('Add key', 'short') + fireEvent.click(saveSearch()) + expect(await within(card()).findByRole('alert')).toHaveTextContent( + 'Not saved: invalid recipe search settings: api_key: must be at least 8 characters.', + ) + }) + + it('shows how many recipes the test found', async () => { + const fetchMock = 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() + expect(sent(fetchMock)).toEqual([{ request: 'POST /api/v1/settings/recipe-search/test', body: undefined }]) + }) + + it('shows the error when the test fails', 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: recipes: SearXNG answered 403 Forbidden', + ) + }) + + it('asks before leaving with an unsaved recipe search change', async () => { + stubAPI() + await renderCard() + fireEvent.change(url(), { target: { value: 'https://search.example' } }) + fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' })) + expect(await screen.findByRole('dialog', { name: 'Leave without saving?' })).toBeInTheDocument() + }) + + it('keeps 44 px tap targets', async () => { + stubAPI({ 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, BRAVE_SETTINGS) }) + await renderCard() + for (const control of [ + searxng(), + brave(), + within(card()).getByRole('button', { name: 'Replace' }), + testSearch(), + saveSearch(), + ]) { + expectMinHitArea(control) + } + }) +}) + describe('maskedKey', () => { it('shows only the last four characters', () => { expect(maskedKey('3f9a')).toBe('••••••••3f9a') diff --git a/web/src/settings/AISettingsPage.tsx b/web/src/settings/AISettingsPage.tsx index 3aecb69..18b8432 100644 --- a/web/src/settings/AISettingsPage.tsx +++ b/web/src/settings/AISettingsPage.tsx @@ -14,6 +14,7 @@ import { TextInput } from '../ui/TextInput.tsx' import { aiKeyPolicy, aiProviderLabels, + maskedKey, providersWithBaseURL, useAIModels, useAISettings, @@ -26,6 +27,7 @@ import { type AISettingsPatch, } from './api.ts' import styles from './AISettingsPage.module.css' +import { RecipeSearchCard } from './RecipeSearchCard.tsx' // TYPE_MODEL is the Model dropdown's "Type a model id" option. const TYPE_MODEL = '\u0000type' @@ -33,9 +35,12 @@ const TYPE_MODEL = '\u0000type' // Admin · AI assistant (handoff mock 1o). Provider, model, temperature and // system prompt are edited as a draft and saved together; the API key is // stored (and verified) on its own, straight away. The client only ever -// sees the key's last four characters. +// sees the key's last four characters. Recipe search is its own card, saved +// on its own. export function AISettingsPage() { const settings = useAISettings() + const [aiDirty, setAIDirty] = useState(false) + const [recipeSearchDirty, setRecipeSearchDirty] = useState(false) return (
@@ -45,11 +50,38 @@ export function AISettingsPage() { AI settings could not load: {settings.error.message} )} - {settings.isSuccess && } + {settings.isSuccess && } + +
) } +// LeaveGuard asks before leaving the screen with unsaved changes. +function LeaveGuard({ dirty }: { dirty: boolean }) { + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname, + ) + useEffect(() => { + if (!dirty) return + const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault() + window.addEventListener('beforeunload', onBeforeUnload) + return () => window.removeEventListener('beforeunload', onBeforeUnload) + }, [dirty]) + return ( + blocker.reset?.()} + onConfirm={() => blocker.proceed?.()} + > + Your changes on this screen have not been saved. + + ) +} + function Header() { const navigate = useNavigate() return ( @@ -95,7 +127,13 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch { return patch } -function AISettingsForm({ saved }: { saved: AISettings }) { +type AISettingsFormProps = { + saved: AISettings + // Called with whether the form holds unsaved changes. + onDirtyChange: (dirty: boolean) => void +} + +function AISettingsForm({ saved, onDirtyChange }: AISettingsFormProps) { const [draft, setDraft] = useState(() => draftOf(saved)) const [typingModel, setTypingModel] = useState(false) const [confirmingReset, setConfirmingReset] = useState(false) @@ -116,15 +154,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) { const listed = canList && models.isSuccess ? models.data : [] const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model)) - const blocker = useBlocker( - ({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname, - ) useEffect(() => { - if (!dirty) return - const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault() - window.addEventListener('beforeunload', onBeforeUnload) - return () => window.removeEventListener('beforeunload', onBeforeUnload) - }, [dirty]) + onDirtyChange(dirty) + }, [dirty, onDirtyChange]) + useEffect(() => () => onDirtyChange(false), [onDirtyChange]) function edit(change: Partial) { save.reset() @@ -285,26 +318,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) { > This restores MealQuest's default system prompt for everyone in the household. - blocker.reset?.()} - onConfirm={() => blocker.proceed?.()} - > - Your changes to the AI assistant have not been saved. - ) } -// maskedKey shows the stored key as dots and its last four characters, all -// the client ever knows of it. -export function maskedKey(last4: string): string { - return `••••••••${last4}` -} - // formatVerifiedDate turns key_verified_at into the handoff's "Oct 1". export function formatVerifiedDate(timestamp: string): string { const date = new Date(timestamp) diff --git a/web/src/settings/RecipeSearchCard.tsx b/web/src/settings/RecipeSearchCard.tsx new file mode 100644 index 0000000..1aa12ad --- /dev/null +++ b/web/src/settings/RecipeSearchCard.tsx @@ -0,0 +1,259 @@ +import { useEffect, useId, useState } from 'react' +import { ApiError } from '../api/index.ts' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { + maskedKey, + recipeSearchProviderLabels, + useRecipeSearchSettings, + useSaveRecipeSearchSettings, + useTestRecipeSearch, + type RecipeSearchProvider, + type RecipeSearchSettings, + type RecipeSearchSettingsPatch, +} from './api.ts' +import styles from './AISettingsPage.module.css' + +const providerOptions = Object.entries(recipeSearchProviderLabels).map(([value, label]) => ({ + value: value as RecipeSearchProvider, + label, +})) + +type RecipeSearchCardProps = { + // Called with whether the card holds unsaved changes. + onDirtyChange: (dirty: boolean) => void +} + +// RecipeSearchCard is where recipes are searched for: a SearXNG instance or +// Brave. The provider, SearXNG URL and a new Brave key are edited as a draft +// and saved together; the client only ever sees the stored key's last four +// characters. Test searches with the saved settings. +export function RecipeSearchCard({ onDirtyChange }: RecipeSearchCardProps) { + const settings = useRecipeSearchSettings() + const titleId = useId() + return ( + +

+ Recipe search +

+

Where MealQuest looks for real recipes behind each Side quest.

+ {settings.isPending && } + {settings.isError && ( + void settings.refetch()}> + Recipe search settings could not load: {settings.error.message} + + )} + {settings.isSuccess && } +
+ ) +} + +type Draft = { + provider: RecipeSearchProvider + base_url: string + // A new Brave key to store, null when the stored one stays. + api_key: string | null +} + +// draftOf starts a household that has not set up recipe search on SearXNG, +// the sidecar every deployment runs. +function draftOf(saved: RecipeSearchSettings): Draft { + return { provider: saved.provider ?? 'searxng', base_url: saved.base_url ?? '', api_key: null } +} + +// patchOf is the PATCH body for what draft changes. Only the chosen +// provider's field is sent: the other provider's is left as it is. +function patchOf(saved: RecipeSearchSettings, draft: Draft): RecipeSearchSettingsPatch { + const patch: RecipeSearchSettingsPatch = {} + if (draft.provider !== saved.provider) patch.provider = draft.provider + if (draft.provider === 'searxng' && draft.base_url !== (saved.base_url ?? '')) patch.base_url = draft.base_url + if (draft.provider === 'brave' && draft.api_key !== null) patch.api_key = draft.api_key + return patch +} + +function RecipeSearchForm({ saved, onDirtyChange }: { saved: RecipeSearchSettings } & RecipeSearchCardProps) { + const [draft, setDraft] = useState(() => draftOf(saved)) + const save = useSaveRecipeSearchSettings() + const test = useTestRecipeSearch() + const patch = patchOf(saved, draft) + const dirty = Object.keys(patch).length > 0 + + useEffect(() => { + onDirtyChange(dirty) + }, [dirty, onDirtyChange]) + useEffect(() => () => onDirtyChange(false), [onDirtyChange]) + + function edit(change: Partial) { + save.reset() + setDraft((current) => ({ ...current, ...change })) + } + + function onSave() { + save.mutate(patch, { + onSuccess: (settings) => { + setDraft(draftOf(settings)) + test.reset() + }, + }) + } + + return ( + <> + edit({ provider })} + /> + {draft.provider === 'searxng' ? ( + edit({ base_url: event.target.value })} + /> + ) : ( + edit({ api_key })} + /> + )} + +
+ {save.isSuccess && !dirty && 'Recipe search saved.'} + {test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`} +
+ {save.isError && ( +

+ {saveErrorMessage(save.error)} +

+ )} + {test.isError && ( +

+ {testErrorMessage(test.error)} +

+ )} + {dirty && saved.provider !== null &&

Save your changes to test them.

} + +
+ + +
+ + ) +} + +type BraveKeyProps = { + last4: string | null + newKey: string | null + onKey: (key: string | null) => void +} + +// BraveKey shows the stored key masked, or the new one waiting to be saved, +// and takes a new key in a sheet. The key is sent only when the card is +// saved. +function BraveKey({ last4, newKey, onKey }: BraveKeyProps) { + const [replacing, setReplacing] = useState(false) + const [key, setKey] = useState('') + const labelId = useId() + const sheetTitleId = useId() + + function close() { + setReplacing(false) + setKey('') + } + + let shown = 'No key stored' + if (newKey !== null) shown = maskedKey(newKey.slice(-4)) + else if (last4 !== null) shown = maskedKey(last4) + + return ( +
+ + Brave API key + +
+ {shown} + +
+ {newKey !== null && ( +
+

New key not saved yet

+ +
+ )} + + +
{ + event.preventDefault() + onKey(key) + close() + }} + > +

+ {last4 === null ? 'Add Brave API key' : 'Replace Brave API key'} +

+

+ The key is stored on the server when you save recipe search. This device only ever sees its last four + characters. +

+ setKey(event.target.value)} + /> +
+ + +
+ +
+
+ ) +} + +function saveErrorMessage(error: Error): string { + if (error instanceof ApiError && error.code === 'invalid_settings') return `Not saved: ${error.message}.` + return `Not saved: ${error.message}` +} + +// testErrorMessage says why POST /settings/recipe-search/test failed, by +// the error code the server names (internal/server/recipesearch.go). +function testErrorMessage(error: Error): string { + if (error instanceof ApiError && error.code === 'not_configured') { + return 'Test failed: recipe search is not set up yet.' + } + return `Test failed: ${error.message}` +} diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index 399dc70..ea7b0ed 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -8,6 +8,8 @@ export const AI_MODELS_PATH = '/api/v1/settings/ai/models' export const AI_KEY_PATH = '/api/v1/settings/ai/api-key' export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify' export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt' +export const RECIPE_SEARCH_PATH = '/api/v1/settings/recipe-search' +export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test' // TODO(Feature 12): GET /api/v1/dinners?status=regular is Feature 12's // dinners read API. Until it lands the server answers 404 and the Regular // dinners row says the count is unavailable. @@ -192,6 +194,105 @@ export function useResetAIPrompt() { return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined) } +// recipeSearchProviderLabels names every recipe search provider the server +// accepts (internal/recipes/searchsettings.go). +export const recipeSearchProviderLabels = { + searxng: 'SearXNG', + brave: 'Brave', +} as const + +export type RecipeSearchProvider = keyof typeof recipeSearchProviderLabels + +// RecipeSearchSettings is GET /api/v1/settings/recipe-search. The Brave API +// key itself is never sent: only whether one is stored and its last four +// characters. +export type RecipeSearchSettings = { + // Null until recipe search is first set up. + provider: RecipeSearchProvider | null + // The SearXNG instance's URL, null when none is set. + base_url: string | null + api_key_set: boolean + api_key_last4: string | null + updated_at: string | null +} + +// RecipeSearchSettingsPatch is the PATCH /api/v1/settings/recipe-search +// body: only the fields that change. api_key is the Brave key in plain text. +export type RecipeSearchSettingsPatch = { + provider?: RecipeSearchProvider + base_url?: string + api_key?: string +} + +// RecipeSearchTest is POST /api/v1/settings/recipe-search/test: how many +// recipes the saved provider found for query. +export type RecipeSearchTest = { query: string; count: number } + +function isRecipeSearchProvider(value: string): value is RecipeSearchProvider { + return Object.hasOwn(recipeSearchProviderLabels, value) +} + +function parseRecipeSearchSettings(where: string, body: unknown): RecipeSearchSettings { + if ( + !isRecord(body) || + !isStringOrNull(body.provider) || + !isStringOrNull(body.base_url) || + typeof body.api_key_set !== 'boolean' || + !isStringOrNull(body.api_key_last4) || + !isStringOrNull(body.updated_at) + ) { + throw new Error(`${where}: response is not the recipe search settings`) + } + if (body.provider !== null && !isRecipeSearchProvider(body.provider)) { + throw new Error(`${where}: unknown recipe search provider "${body.provider}"`) + } + return { + provider: body.provider, + base_url: body.base_url, + api_key_set: body.api_key_set, + api_key_last4: body.api_key_last4, + updated_at: body.updated_at, + } +} + +const RECIPE_SEARCH_KEY = ['settings', 'recipe-search'] as const + +export function useRecipeSearchSettings() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: RECIPE_SEARCH_KEY, + queryFn: async () => + parseRecipeSearchSettings(`GET ${RECIPE_SEARCH_PATH}`, await apiFetch(RECIPE_SEARCH_PATH)), + }) +} + +export function useSaveRecipeSearchSettings() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (patch: RecipeSearchSettingsPatch) => + parseRecipeSearchSettings( + `PATCH ${RECIPE_SEARCH_PATH}`, + await apiFetch(RECIPE_SEARCH_PATH, { method: 'PATCH', body: patch }), + ), + onSuccess: (settings) => queryClient.setQueryData(RECIPE_SEARCH_KEY, settings), + }) +} + +// useTestRecipeSearch searches with the saved settings. +export function useTestRecipeSearch() { + const { apiFetch } = useApi() + return useMutation({ + mutationFn: async () => { + const body = await apiFetch(RECIPE_SEARCH_TEST_PATH, { method: 'POST' }) + if (!isRecord(body) || typeof body.query !== 'string' || typeof body.count !== 'number') { + throw new Error(`POST ${RECIPE_SEARCH_TEST_PATH}: response is not a search test result`) + } + return { query: body.query, count: body.count } satisfies RecipeSearchTest + }, + }) +} + const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ // useHouseholdSettings refuses a dinner time that is not HH:MM, so @@ -321,6 +422,12 @@ export function notificationsStatus(status: NotificationsStatus | null): string return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}` } +// maskedKey shows a stored key as dots and its last four characters, all +// the client ever knows of it. +export function maskedKey(last4: string): string { + return `••••••••${last4}` +} + // formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm". export function formatClockTime(time: string): string { const match = clockTime.exec(time) diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index 153c5de..1f2076d 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -1,9 +1,9 @@ import { render, screen, within } from '@testing-library/react' -import { MemoryRouter } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { AppRoutes } from '../App.tsx' import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx' import { declaredStyle, outerSize } from '../test/cssRules.ts' +import { InDataRouter } from '../test/dataRouter.tsx' import { expectMinHitArea } from '../test/hitArea.ts' import { stubFetchByPath } from '../test/fetchStub.ts' import { mockViewport } from '../test/matchMedia.ts' @@ -25,11 +25,11 @@ function renderAt(path: string, width: number) { const resize = mockViewport(width) stubFetchByPath(SETTINGS_API) render( - + - , + , ) return resize } diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index b0c1650..bad365d 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -16,6 +16,16 @@ export const AI_SETTINGS = { updated_at: '2026-10-01T15:00:00.000Z', } +// RECIPE_SEARCH_SETTINGS is a GET /api/v1/settings/recipe-search body: the +// SearXNG sidecar, no Brave key stored. +export const RECIPE_SEARCH_SETTINGS = { + provider: 'searxng', + base_url: 'http://searxng:8080', + api_key_set: false, + api_key_last4: null, + updated_at: '2026-10-01T15:00:00.000Z', +} + // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as // today's server does: the dinners, Google Calendar and notifications status // endpoints do not exist yet. @@ -25,6 +35,7 @@ export const SETTINGS_API = { '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), '/api/v1/settings/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), + '/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), } // ApiProviders gives the pages under test what App gives them: an API client