Recipe search card on the AI assistant screen
Add a Recipe search card to /settings/ai: a SearXNG | Brave segmented control, the SearXNG URL pre-filled from the saved settings, or the Brave API key shown masked (last four characters) with Replace. The provider, URL and new key are a draft saved together with PATCH /api/v1/settings/recipe-search; Test calls the recipe-search test endpoint and shows "Found N recipes for 'chicken tikka masala'" or the error. The leave-without-saving guard moves to page level so it covers both the AI assistant form and the card; the shell test now renders in a data router as main.tsx does. Maestro task: 0698ae2c-f65e-408e-a9c6-198edc2c5696 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, Responder> = {}): Mock<typeof fetch>
|
||||
'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<typeof fetch>((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')
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.page}>
|
||||
<Header />
|
||||
@@ -45,11 +50,38 @@ export function AISettingsPage() {
|
||||
AI settings could not load: {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{settings.isSuccess && <AISettingsForm saved={settings.data} />}
|
||||
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={setAIDirty} />}
|
||||
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
|
||||
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Confirm
|
||||
open={blocker.state === 'blocked'}
|
||||
title="Leave without saving?"
|
||||
confirmLabel="Leave"
|
||||
cancelLabel="Keep editing"
|
||||
onCancel={() => blocker.reset?.()}
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
>
|
||||
Your changes on this screen have not been saved.
|
||||
</Confirm>
|
||||
)
|
||||
}
|
||||
|
||||
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<Draft>) {
|
||||
save.reset()
|
||||
@@ -285,26 +318,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
|
||||
>
|
||||
This restores MealQuest's default system prompt for everyone in the household.
|
||||
</Confirm>
|
||||
<Confirm
|
||||
open={blocker.state === 'blocked'}
|
||||
title="Leave without saving?"
|
||||
confirmLabel="Leave"
|
||||
cancelLabel="Keep editing"
|
||||
onCancel={() => blocker.reset?.()}
|
||||
onConfirm={() => blocker.proceed?.()}
|
||||
>
|
||||
Your changes to the AI assistant have not been saved.
|
||||
</Confirm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -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 (
|
||||
<Card className={styles.card} aria-labelledby={titleId} role="region">
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
Recipe search
|
||||
</h2>
|
||||
<p className={styles.hint}>Where MealQuest looks for real recipes behind each Side quest.</p>
|
||||
{settings.isPending && <Skeleton className={styles.cardLoading} />}
|
||||
{settings.isError && (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Recipe search settings could not load: {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{settings.isSuccess && <RecipeSearchForm saved={settings.data} onDirtyChange={onDirtyChange} />}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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<Draft>) {
|
||||
save.reset()
|
||||
setDraft((current) => ({ ...current, ...change }))
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save.mutate(patch, {
|
||||
onSuccess: (settings) => {
|
||||
setDraft(draftOf(settings))
|
||||
test.reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Segmented
|
||||
label="Recipe search provider"
|
||||
options={providerOptions}
|
||||
value={draft.provider}
|
||||
onValueChange={(provider) => edit({ provider })}
|
||||
/>
|
||||
{draft.provider === 'searxng' ? (
|
||||
<TextInput
|
||||
label="SearXNG URL"
|
||||
type="url"
|
||||
className={styles.mono}
|
||||
value={draft.base_url}
|
||||
placeholder="https://"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => edit({ base_url: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<BraveKey
|
||||
last4={saved.api_key_last4}
|
||||
newKey={draft.api_key}
|
||||
onKey={(api_key) => edit({ api_key })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.status} role="status">
|
||||
{save.isSuccess && !dirty && 'Recipe search saved.'}
|
||||
{test.isSuccess && `Found ${test.data.count} ${test.data.count === 1 ? 'recipe' : 'recipes'} for '${test.data.query}'`}
|
||||
</div>
|
||||
{save.isError && (
|
||||
<p className={styles.error} role="alert">
|
||||
{saveErrorMessage(save.error)}
|
||||
</p>
|
||||
)}
|
||||
{test.isError && (
|
||||
<p className={styles.error} role="alert">
|
||||
{testErrorMessage(test.error)}
|
||||
</p>
|
||||
)}
|
||||
{dirty && saved.provider !== null && <p className={styles.note}>Save your changes to test them.</p>}
|
||||
|
||||
<div className={styles.cardActions}>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={dirty || saved.provider === null || test.isPending}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{test.isPending ? 'Testing…' : 'Test'}
|
||||
</Button>
|
||||
<Button disabled={!dirty || save.isPending} onClick={onSave}>
|
||||
{save.isPending ? 'Saving…' : 'Save recipe search'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={styles.key}>
|
||||
<span id={labelId} className={styles.label}>
|
||||
Brave API key
|
||||
</span>
|
||||
<div className={styles.keyRow} role="group" aria-labelledby={labelId}>
|
||||
<span className={styles.keyValue}>{shown}</span>
|
||||
<button type="button" className={styles.replace} onClick={() => setReplacing(true)}>
|
||||
{last4 === null && newKey === null ? 'Add key' : 'Replace'}
|
||||
</button>
|
||||
</div>
|
||||
{newKey !== null && (
|
||||
<div className={styles.keyStatus}>
|
||||
<p className={styles.note}>New key not saved yet</p>
|
||||
<button type="button" className={styles.link} onClick={() => onKey(null)}>
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet open={replacing} onClose={close} aria-labelledby={sheetTitleId}>
|
||||
<form
|
||||
className={styles.sheet}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onKey(key)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<h2 id={sheetTitleId} className={styles.sheetTitle}>
|
||||
{last4 === null ? 'Add Brave API key' : 'Replace Brave API key'}
|
||||
</h2>
|
||||
<p className={styles.sheetText}>
|
||||
The key is stored on the server when you save recipe search. This device only ever sees its last four
|
||||
characters.
|
||||
</p>
|
||||
<TextInput
|
||||
label="Brave API key"
|
||||
type="password"
|
||||
className={styles.mono}
|
||||
value={key}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => setKey(event.target.value)}
|
||||
/>
|
||||
<div className={styles.sheetActions}>
|
||||
<Button variant="outline" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={key === ''}>
|
||||
Use this key
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
@@ -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<unknown>(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<unknown>(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<unknown>(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)
|
||||
|
||||
@@ -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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<InDataRouter path={path}>
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</MemoryRouter>,
|
||||
</InDataRouter>,
|
||||
)
|
||||
return resize
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user