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 { mockViewport } from './test/matchMedia.ts'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||||
import App, { routes } from './App.tsx'
|
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'
|
import { InDataRouter } from './test/dataRouter.tsx'
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -80,6 +80,7 @@ describe('App routes', () => {
|
|||||||
'/api/v1/me': () => jsonResponse(200, ME),
|
'/api/v1/me': () => jsonResponse(200, ME),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
'/api/v1/settings/ai/models': () => jsonResponse(200, { models: [] }),
|
||||||
|
'/api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
})
|
})
|
||||||
renderAt('/settings/ai')
|
renderAt('/settings/ai')
|
||||||
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
|
const heading = await screen.findByRole('heading', { level: 1, name: 'AI assistant' })
|
||||||
|
|||||||
@@ -251,3 +251,33 @@
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
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 { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.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 { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.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 = {
|
const MODELS = {
|
||||||
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/me': () => jsonResponse(200, ME),
|
||||||
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
|
'GET /api/v1/settings/ai/models': () => jsonResponse(200, MODELS),
|
||||||
|
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
...responders,
|
...responders,
|
||||||
}
|
}
|
||||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
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', () => {
|
describe('maskedKey', () => {
|
||||||
it('shows only the last four characters', () => {
|
it('shows only the last four characters', () => {
|
||||||
expect(maskedKey('3f9a')).toBe('••••••••3f9a')
|
expect(maskedKey('3f9a')).toBe('••••••••3f9a')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { TextInput } from '../ui/TextInput.tsx'
|
|||||||
import {
|
import {
|
||||||
aiKeyPolicy,
|
aiKeyPolicy,
|
||||||
aiProviderLabels,
|
aiProviderLabels,
|
||||||
|
maskedKey,
|
||||||
providersWithBaseURL,
|
providersWithBaseURL,
|
||||||
useAIModels,
|
useAIModels,
|
||||||
useAISettings,
|
useAISettings,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
type AISettingsPatch,
|
type AISettingsPatch,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
import styles from './AISettingsPage.module.css'
|
import styles from './AISettingsPage.module.css'
|
||||||
|
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||||
|
|
||||||
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
|
// TYPE_MODEL is the Model dropdown's "Type a model id" option.
|
||||||
const TYPE_MODEL = '\u0000type'
|
const TYPE_MODEL = '\u0000type'
|
||||||
@@ -33,9 +35,12 @@ const TYPE_MODEL = '\u0000type'
|
|||||||
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
|
// Admin · AI assistant (handoff mock 1o). Provider, model, temperature and
|
||||||
// system prompt are edited as a draft and saved together; the API key is
|
// 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
|
// 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() {
|
export function AISettingsPage() {
|
||||||
const settings = useAISettings()
|
const settings = useAISettings()
|
||||||
|
const [aiDirty, setAIDirty] = useState(false)
|
||||||
|
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
<Header />
|
<Header />
|
||||||
@@ -45,11 +50,38 @@ export function AISettingsPage() {
|
|||||||
AI settings could not load: {settings.error.message}
|
AI settings could not load: {settings.error.message}
|
||||||
</ErrorBanner>
|
</ErrorBanner>
|
||||||
)}
|
)}
|
||||||
{settings.isSuccess && <AISettingsForm saved={settings.data} />}
|
{settings.isSuccess && <AISettingsForm saved={settings.data} onDirtyChange={setAIDirty} />}
|
||||||
|
<RecipeSearchCard onDirtyChange={setRecipeSearchDirty} />
|
||||||
|
<LeaveGuard dirty={aiDirty || recipeSearchDirty} />
|
||||||
</div>
|
</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() {
|
function Header() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
return (
|
return (
|
||||||
@@ -95,7 +127,13 @@ function patchOf(saved: AISettings, draft: Draft): AISettingsPatch {
|
|||||||
return patch
|
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 [draft, setDraft] = useState(() => draftOf(saved))
|
||||||
const [typingModel, setTypingModel] = useState(false)
|
const [typingModel, setTypingModel] = useState(false)
|
||||||
const [confirmingReset, setConfirmingReset] = useState(false)
|
const [confirmingReset, setConfirmingReset] = useState(false)
|
||||||
@@ -116,15 +154,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
|
|||||||
const listed = canList && models.isSuccess ? models.data : []
|
const listed = canList && models.isSuccess ? models.data : []
|
||||||
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
|
const typedModel = typingModel || !(draft.model === '' || listed.some((m) => m.id === draft.model))
|
||||||
|
|
||||||
const blocker = useBlocker(
|
|
||||||
({ currentLocation, nextLocation }) => dirty && currentLocation.pathname !== nextLocation.pathname,
|
|
||||||
)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dirty) return
|
onDirtyChange(dirty)
|
||||||
const onBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault()
|
}, [dirty, onDirtyChange])
|
||||||
window.addEventListener('beforeunload', onBeforeUnload)
|
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||||
return () => window.removeEventListener('beforeunload', onBeforeUnload)
|
|
||||||
}, [dirty])
|
|
||||||
|
|
||||||
function edit(change: Partial<Draft>) {
|
function edit(change: Partial<Draft>) {
|
||||||
save.reset()
|
save.reset()
|
||||||
@@ -285,26 +318,10 @@ function AISettingsForm({ saved }: { saved: AISettings }) {
|
|||||||
>
|
>
|
||||||
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>
|
||||||
<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".
|
// formatVerifiedDate turns key_verified_at into the handoff's "Oct 1".
|
||||||
export function formatVerifiedDate(timestamp: string): string {
|
export function formatVerifiedDate(timestamp: string): string {
|
||||||
const date = new Date(timestamp)
|
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_KEY_PATH = '/api/v1/settings/ai/api-key'
|
||||||
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
|
export const AI_VERIFY_PATH = '/api/v1/settings/ai/verify'
|
||||||
export const AI_RESET_PROMPT_PATH = '/api/v1/settings/ai/reset-prompt'
|
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
|
// 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 read API. Until it lands the server answers 404 and the Regular
|
||||||
// dinners row says the count is unavailable.
|
// dinners row says the count is unavailable.
|
||||||
@@ -192,6 +194,105 @@ export function useResetAIPrompt() {
|
|||||||
return useAISettingsMutation(AI_RESET_PROMPT_PATH, 'POST', () => undefined)
|
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])$/
|
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||||
|
|
||||||
// useHouseholdSettings refuses a dinner time that is not HH:MM, so
|
// 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'}`
|
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".
|
// formatClockTime turns a 24-hour HH:MM time into the handoff's "5:30 pm".
|
||||||
export function formatClockTime(time: string): string {
|
export function formatClockTime(time: string): string {
|
||||||
const match = clockTime.exec(time)
|
const match = clockTime.exec(time)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { render, screen, within } from '@testing-library/react'
|
import { render, screen, within } from '@testing-library/react'
|
||||||
import { MemoryRouter } from 'react-router'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { AppRoutes } from '../App.tsx'
|
import { AppRoutes } from '../App.tsx'
|
||||||
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
||||||
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { stubFetchByPath } from '../test/fetchStub.ts'
|
import { stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
@@ -25,11 +25,11 @@ function renderAt(path: string, width: number) {
|
|||||||
const resize = mockViewport(width)
|
const resize = mockViewport(width)
|
||||||
stubFetchByPath(SETTINGS_API)
|
stubFetchByPath(SETTINGS_API)
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<InDataRouter path={path}>
|
||||||
<ApiProviders>
|
<ApiProviders>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ApiProviders>
|
</ApiProviders>
|
||||||
</MemoryRouter>,
|
</InDataRouter>,
|
||||||
)
|
)
|
||||||
return resize
|
return resize
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export const AI_SETTINGS = {
|
|||||||
updated_at: '2026-10-01T15:00:00.000Z',
|
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
|
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||||
// today's server does: the dinners, Google Calendar and notifications status
|
// today's server does: the dinners, Google Calendar and notifications status
|
||||||
// endpoints do not exist yet.
|
// endpoints do not exist yet.
|
||||||
@@ -25,6 +35,7 @@ export const SETTINGS_API = {
|
|||||||
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
'/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/household': () => jsonResponse(200, { timezone: 'America/New_York', dinner_time: '17:30' }),
|
||||||
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'/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
|
// ApiProviders gives the pages under test what App gives them: an API client
|
||||||
|
|||||||
Reference in New Issue
Block a user