diff --git a/web/src/pages/AddPage.tsx b/web/src/pages/AddPage.tsx index 93c0d4d..8ba6301 100644 --- a/web/src/pages/AddPage.tsx +++ b/web/src/pages/AddPage.tsx @@ -1,3 +1,200 @@ -export default function AddPage() { - return

Search goes here.

+import { useCallback, useEffect, useReducer, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { api, ApiError } from '../api.js' +import CandidateCard from '../components/CandidateCard.js' +import ConfirmView from '../scan/ConfirmView.js' +import Cover from '../components/Cover.js' +import { scanReducer, INITIAL_SCAN_STATE, type ScanErrorKind } from '../scan/reducer.js' + +function toErrorKind(err: unknown): ScanErrorKind { + if (err instanceof ApiError) { + if (err.code === 'not_found') return 'not_found' + if (err.code === 'no_discogs_token') return 'no_discogs_token' + if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited' + } + return 'server' +} + +const FORMATS = ['Vinyl', 'CD', 'Cassette'] as const + +export default function AddPage() { + const [params] = useSearchParams() + const [q, setQ] = useState(params.get('q') ?? '') + const [format, setFormat] = useState<(typeof FORMATS)[number] | ''>('') + const [state, dispatch] = useReducer(scanReducer, INITIAL_SCAN_STATE) + + const search = useCallback( + (query: string) => { + if (!query.trim()) return + dispatch({ type: 'DETECT', code: query.trim() }) + void (async () => { + try { + const { candidates } = await api.lookupSearch(query.trim(), format || undefined) + if (candidates.length === 0) { + dispatch({ type: 'NOT_FOUND', code: query.trim() }) + } else { + dispatch({ type: 'CANDIDATES', code: query.trim(), candidates }) + } + } catch (err) { + dispatch({ type: 'ERROR', kind: toErrorKind(err), code: query.trim(), source: 'lookup' }) + } + })() + }, + [format] + ) + + // Load the release preview once a candidate is selected. + useEffect(() => { + if (state.phase !== 'confirm' || state.preview) return + const candidateId = state.candidate.id + void (async () => { + try { + const preview = await api.getReleasePreview(candidateId) + dispatch({ type: 'PREVIEW', preview, candidateId }) + } catch (err) { + dispatch({ type: 'ERROR', kind: toErrorKind(err), code: null, source: 'preview', candidateId }) + } + })() + }, [state]) + + const add = useCallback(() => { + if (state.phase !== 'confirm' || !state.preview || state.adding) return + const { candidate, matchAlbumId } = state + dispatch({ type: 'ADD_START' }) + void (async () => { + try { + const item = await api.addToCollection({ + releaseId: candidate.id, + ...(matchAlbumId !== null ? { matchAlbumId } : {}), + }) + dispatch({ type: 'ADDED', item }) + } catch (err) { + const message = + err instanceof ApiError + ? err.code === 'duplicate' + ? 'Already in your collection.' + : err.detail ?? err.code + : 'Something went wrong' + dispatch({ type: 'ADD_ERROR', message }) + } + })() + }, [state]) + + return ( +
+ {state.phase === 'scan' && ( +
{ + e.preventDefault() + search(q) + }} + className="space-y-3" + > + setQ(e.target.value)} + placeholder="Artist and title" + className="w-full rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm" + /> +
+ + + +
+ + or scan a barcode → + +
+ )} + + {state.phase === 'looking' &&

Searching…

} + + {state.phase === 'candidates' && ( +
+

Which release is it?

+ {state.candidates.map((c) => ( + dispatch({ type: 'SELECT', candidate: cand })} /> + ))} +
+ )} + + {state.phase === 'confirm' && ( +
+ {!state.preview &&

Checking release…

} + {state.preview && ( + dispatch({ type: 'SET_MATCH', albumId })} + onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })} + onAdd={add} + adding={state.adding} + addError={state.addError} + /> + )} + +
+ )} + + {state.phase === 'added' && ( +
+ +

Added to collection ✓

+ + View item + + +
+ )} + + {state.phase === 'error' && ( +
+

+ {state.kind === 'not_found' ? 'Nothing found' : 'Search failed'} +

+

+ {state.kind === 'not_found' && 'Try different spelling, or add the year.'} + {state.kind === 'no_discogs_token' && 'Add your Discogs token in Settings first.'} + {state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'} +

+ +
+ )} +
+ ) } diff --git a/web/test/add.test.tsx b/web/test/add.test.tsx new file mode 100644 index 0000000..b5df8ce --- /dev/null +++ b/web/test/add.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import AddPage from '../src/pages/AddPage.js' +import { api, ApiError } from '../src/api.js' +import type { Candidate, ReleasePreview } from '../src/types.js' + +vi.mock('../src/api.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, api: { ...actual.api, lookupSearch: vi.fn(), getReleasePreview: vi.fn(), addToCollection: vi.fn() } } +}) + +const candidate: Candidate = { + id: 1001, + artist: 'The Cinematic Orchestra', + title: 'Motion', + year: 1999, + formats: ['Vinyl'], + labels: ['Ninja Tune'], + country: 'UK', + catno: 'ZEN012', + thumbUrl: null, +} + +const preview: ReleasePreview = { + release: { ...candidate, genres: [], tracklist: [], coverUrl: null, barcodes: [] }, + duplicate: false, + ripMatch: 'not_ripped', + matchCandidates: [], +} + +// The api module is mocked directly, so mocks resolve with parsed bodies. +function jsonOk(body: unknown) { + return Promise.resolve(body) +} + +beforeEach(() => { + vi.mocked(api.lookupSearch).mockReset() + vi.mocked(api.getReleasePreview).mockReset() + vi.mocked(api.addToCollection).mockReset() +}) + +function renderAdd(initialEntry = '/add') { + return render( + + + + ) +} + +describe('AddPage', () => { + it('searches discogs and shows candidate cards', async () => { + vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never) + renderAdd() + await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'cinematic orchestra motion{Enter}') + await waitFor(() => + expect(api.lookupSearch).toHaveBeenCalledWith('cinematic orchestra motion', undefined) + ) + expect(await screen.findByRole('button', { name: /motion/i })).toBeTruthy() + }) + + it('passes the format filter and pre-filled query from the URL', async () => { + vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [] }) as never) + renderAdd('/add?q=5021592210629') + expect((screen.getByPlaceholderText(/artist and title/i) as HTMLInputElement).value).toBe( + '5021592210629' + ) + await userEvent.selectOptions(screen.getByLabelText(/format/i), 'Vinyl') + await userEvent.click(screen.getByRole('button', { name: /^search$/i })) + await waitFor(() => expect(api.lookupSearch).toHaveBeenCalledWith('5021592210629', 'Vinyl')) + }) + + it('shows a not-found message', async () => { + vi.mocked(api.lookupSearch).mockRejectedValue(new ApiError(404, 'not_found')) + renderAdd() + await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'zzz{Enter}') + await waitFor(() => expect(screen.getByText(/nothing found/i)).toBeTruthy()) + }) + + it('selecting a candidate loads the confirm view and adds', async () => { + vi.mocked(api.lookupSearch).mockResolvedValue(jsonOk({ candidates: [candidate] }) as never) + vi.mocked(api.getReleasePreview).mockResolvedValue(jsonOk(preview) as never) + vi.mocked(api.addToCollection).mockResolvedValue(jsonOk({}) as never) + renderAdd() + await userEvent.type(screen.getByPlaceholderText(/artist and title/i), 'motion{Enter}') + await userEvent.click(await screen.findByRole('button', { name: /motion/i })) + await waitFor(() => expect(screen.getByText('Not ripped yet')).toBeTruthy()) + await userEvent.click(screen.getByRole('button', { name: /add to collection/i })) + await waitFor(() => expect(api.addToCollection).toHaveBeenCalledWith({ releaseId: 1001 })) + }) +})