Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b96dea5d8 |
@@ -203,6 +203,22 @@ describe('CookFromPantryPage', () => {
|
||||
expect(chips()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('enables Find ideas for text typed and not yet added, and searches it', async () => {
|
||||
const { fetchMock } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
await renderPage()
|
||||
|
||||
fireEvent.change(input(), { target: { value: ' ' } })
|
||||
expect(findButton()).toBeDisabled()
|
||||
fireEvent.change(input(), { target: { value: 'Chicken thighs' } })
|
||||
expect(chips()).toEqual([])
|
||||
expect(findButton()).toBeEnabled()
|
||||
|
||||
fireEvent.click(findButton())
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: ['Chicken thighs'], quick_only: false })
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
})
|
||||
|
||||
it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
@@ -348,6 +364,39 @@ describe('CookFromPantryPage', () => {
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('says an idea that came back as an existing Regular is already a Regular, not in the Quest log', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||
jsonResponse(200, dinnerBody('dn-reg', 'Coconut Chicken Curry', { status: 'regular' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent('Already a Regular')
|
||||
expect(curry.queryByText('In the Quest log')).toBeNull()
|
||||
})
|
||||
|
||||
it('fails on a dinner whose status is neither quest_log nor regular', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'suggested' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent(
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log: status "suggested" is not one of quest_log, regular',
|
||||
)
|
||||
expect(curry.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows its failure on its card and lets it be pressed again', async () => {
|
||||
let fail = true
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
ADDED_LABEL,
|
||||
FROM_INGREDIENTS_JOB_KIND,
|
||||
IDEAS_KEPT,
|
||||
ideasJobFailure,
|
||||
@@ -50,9 +51,10 @@ function fold(ingredient: string): string {
|
||||
|
||||
// IdeaCard is one idea (mock 1i): its name and time, the server's line on
|
||||
// what it uses and what is missing, and its two actions, each showing its
|
||||
// own progress and failure. ref is the idea in its job, null while the job
|
||||
// runs: the server takes an idea only once the job has succeeded, as the
|
||||
// ideas may still be reordered until then.
|
||||
// own progress and failure; an idea added says whether its dinner is in
|
||||
// the Quest log or is already a Regular. ref is the idea in its job, null
|
||||
// while the job runs: the server takes an idea only once the job has
|
||||
// succeeded, as the ideas may still be reordered until then.
|
||||
function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) {
|
||||
const questLog = useAddIdeaToQuestLog()
|
||||
const tonight = useCookIdeaTonight()
|
||||
@@ -80,7 +82,7 @@ function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) {
|
||||
)}
|
||||
{questLog.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> In the Quest log
|
||||
<Icon icon={Check} /> {ADDED_LABEL[questLog.data.status]}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
@@ -123,10 +125,12 @@ function Skeletons({ count }: { count: number }) {
|
||||
|
||||
// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on
|
||||
// hand as chips (Enter or a comma adds one), Any time | Quick only, and
|
||||
// Find ideas, which starts an ideas_from_ingredients job and shows its
|
||||
// ideas as the server grounds them, skeleton cards holding the places of
|
||||
// those still to come. Changing Any time | Quick only after a search asks
|
||||
// again: the server, not the screen, says what is quick.
|
||||
// Find ideas, which lists what is still typed, so it is enabled by a chip
|
||||
// or by text typed and not yet added, and starts an ideas_from_ingredients
|
||||
// job and shows its ideas as the server grounds them, skeleton cards
|
||||
// holding the places of those still to come. Changing Any time | Quick
|
||||
// only after a search asks again: the server, not the screen, says what is
|
||||
// quick.
|
||||
export function CookFromPantryPage() {
|
||||
const navigate = useNavigate()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -302,7 +306,12 @@ export function CookFromPantryPage() {
|
||||
)}
|
||||
<div className={styles.controls}>
|
||||
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
|
||||
<Button variant="dark" className={styles.find} disabled={ingredients.length < 1 || findIdeas.isPending} onClick={find}>
|
||||
<Button
|
||||
variant="dark"
|
||||
className={styles.find}
|
||||
disabled={(ingredients.length < 1 && draft.trim() === '') || findIdeas.isPending}
|
||||
onClick={find}
|
||||
>
|
||||
Find ideas
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -279,6 +279,28 @@ describe('QuickDinnersPage', () => {
|
||||
expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels the check Already a Regular when the idea came back as an existing Regular', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], {
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-reg', ORZO.name, { status: 'regular' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
const orzo = within(row(ORZO.name))
|
||||
expect(await orzo.findByRole('status')).toContainElement(orzo.getByRole('img', { name: 'Already a Regular' }))
|
||||
expect(orzo.queryByRole('img', { name: 'In the Quest log' })).toBeNull()
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
|
||||
|
||||
// Kept across tabs as what it is.
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(within(row(ORZO.name)).getByRole('img', { name: 'Already a Regular' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks an add that settles while another tab is shown', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise })
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import {
|
||||
ADDED_LABEL,
|
||||
ideasJobFailure,
|
||||
ideasJobLost,
|
||||
MAX_EXCLUDE,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
useAddIdeaToQuestLog,
|
||||
useFindQuickIdeas,
|
||||
useQuickTab,
|
||||
type AddedDinner,
|
||||
type AddedStatus,
|
||||
type IdeaRef,
|
||||
type QuickIdea,
|
||||
type QuickKind,
|
||||
@@ -57,7 +60,8 @@ function meta(idea: QuickIdea): string {
|
||||
}
|
||||
|
||||
// QuickRow is one idea (mock 1j): its washed photo, name and meta line, and
|
||||
// the teal "+" that adds it to the Quest log, a check once it is there.
|
||||
// the teal "+" that adds it to the Quest log, then a check labelled with
|
||||
// where it went: added is the status of the dinner it came back as.
|
||||
// ideaRef is null while its job runs: the server takes an idea only once
|
||||
// the job has succeeded. onAdded marks the idea added, also if the row has
|
||||
// unmounted before the add settles.
|
||||
@@ -69,8 +73,8 @@ function QuickRow({
|
||||
}: {
|
||||
idea: QuickIdea
|
||||
ideaRef: IdeaRef | null
|
||||
added: boolean
|
||||
onAdded: (idea: IdeaRef) => void
|
||||
added: AddedStatus | undefined
|
||||
onAdded: (idea: IdeaRef, dinner: AddedDinner) => void
|
||||
}) {
|
||||
const questLog = useAddIdeaToQuestLog(onAdded)
|
||||
return (
|
||||
@@ -85,9 +89,9 @@ function QuickRow({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{added ? (
|
||||
{added !== undefined ? (
|
||||
<span className={styles.added} role="status">
|
||||
<Icon icon={Check} size="nav" label="In the Quest log" />
|
||||
<Icon icon={Check} size="nav" label={ADDED_LABEL[added]} />
|
||||
</span>
|
||||
) : (
|
||||
<IconButton
|
||||
@@ -163,7 +167,8 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) {
|
||||
else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t))
|
||||
}, [jobId, ideas, exhausted, update])
|
||||
|
||||
const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] }))
|
||||
const markAdded = ({ jobId, index }: IdeaRef, { status }: AddedDinner) =>
|
||||
update((t) => ({ ...t, added: { ...t.added, [`${jobId}:${index}`]: status } }))
|
||||
|
||||
// The rows of every page found, then of the page being found, in one
|
||||
// keyed list: a page kept with the tab's pages keeps its rows, and with
|
||||
@@ -223,7 +228,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) {
|
||||
<p className={styles.helper}>{helpers[kind]}</p>
|
||||
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
|
||||
{rows.map(({ idea, ref, key }) => (
|
||||
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added.includes(key)} onAdded={markAdded} />
|
||||
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added[key]} onAdded={markAdded} />
|
||||
))}
|
||||
{next}
|
||||
</ul>
|
||||
|
||||
+45
-14
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts'
|
||||
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method, type Regular } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { parsePlan, planKey } from '../plan/api.ts'
|
||||
|
||||
@@ -221,11 +221,17 @@ export type QuickPage = { jobId: string; ideas: QuickIdea[] }
|
||||
// QuickTab is what one tab of Quick dinners has found this session: the
|
||||
// pages found, the job finding the next one, whether the server has said
|
||||
// there are no more, why the last page asked for could not start, kept
|
||||
// until Retry asks again, and the ideas added to the Quest log, as
|
||||
// "jobId:index".
|
||||
export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] }
|
||||
// until Retry asks again, and the status of the dinner each idea added
|
||||
// came back as, by "jobId:index".
|
||||
export type QuickTab = {
|
||||
pages: QuickPage[]
|
||||
jobId: string | null
|
||||
ended: boolean
|
||||
startError: Error | null
|
||||
added: Record<string, AddedStatus>
|
||||
}
|
||||
|
||||
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] }
|
||||
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: {} }
|
||||
|
||||
export function quickTabKey(kind: QuickKind) {
|
||||
return ['ideas', 'quick', kind] as const
|
||||
@@ -286,21 +292,46 @@ function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight
|
||||
return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}`
|
||||
}
|
||||
|
||||
// ADDED_STATUSES are the statuses a dinner an idea is added as may have:
|
||||
// quest_log for a dinner made or already in the Quest log, regular for a
|
||||
// Regular the idea repeats, which the server answers as it is.
|
||||
export const ADDED_STATUSES = ['quest_log', 'regular'] as const
|
||||
export type AddedStatus = (typeof ADDED_STATUSES)[number]
|
||||
|
||||
// ADDED_LABEL says where an idea added went, by its dinner's status.
|
||||
export const ADDED_LABEL: Record<AddedStatus, string> = {
|
||||
quest_log: 'In the Quest log',
|
||||
regular: 'Already a Regular',
|
||||
}
|
||||
|
||||
// AddedDinner is the dinner an idea was added as, with its status.
|
||||
export type AddedDinner = Regular & { status: AddedStatus }
|
||||
|
||||
function parseAddedDinner(where: string, body: unknown): AddedDinner {
|
||||
const dinner = parseDinner(where, body)
|
||||
const status = isRecord(body) ? body.status : undefined
|
||||
if (!(ADDED_STATUSES as readonly unknown[]).includes(status)) {
|
||||
throw new Error(`${where}: status ${JSON.stringify(status)} is not one of ${ADDED_STATUSES.join(', ')}`)
|
||||
}
|
||||
return { ...dinner, status: status as AddedStatus }
|
||||
}
|
||||
|
||||
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
|
||||
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the
|
||||
// dinner's name. Asking again answers the same dinner. The Quest log is
|
||||
// read again after it, and onAdded told, also if the caller has unmounted
|
||||
// meanwhile.
|
||||
export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) {
|
||||
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the dinner
|
||||
// it came back as: a dinner the idea repeats is answered as it is, so a
|
||||
// Regular stays a Regular, and its status says which. Asking again answers
|
||||
// the same dinner. The Quest log is read again after it, and onAdded told
|
||||
// with the dinner, also if the caller has unmounted meanwhile.
|
||||
export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef, dinner: AddedDinner) => void) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (idea: IdeaRef): Promise<string> => {
|
||||
mutationFn: async (idea: IdeaRef): Promise<AddedDinner> => {
|
||||
const path = ideaPath(idea, 'quest-log')
|
||||
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
|
||||
return parseAddedDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: (_name, idea) => {
|
||||
onAdded?.(idea)
|
||||
onSuccess: (dinner, idea) => {
|
||||
onAdded?.(idea, dinner)
|
||||
return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user