feat: add page with discogs text search into confirm flow
This commit is contained in:
@@ -1,3 +1,200 @@
|
||||
export default function AddPage() {
|
||||
return <p className="text-neutral-400">Search goes here.</p>
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{state.phase === 'scan' && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
search(q)
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<label className="sr-only" htmlFor="format">
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
id="format"
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value as (typeof FORMATS)[number] | '')}
|
||||
className="rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any format</option>
|
||||
{FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="flex-1 rounded-xl bg-emerald-500 py-2 font-medium text-neutral-950">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
<Link to="/scan" className="block text-center text-sm text-neutral-400">
|
||||
or scan a barcode →
|
||||
</Link>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{state.phase === 'looking' && <p className="py-8 text-center text-neutral-400">Searching…</p>}
|
||||
|
||||
{state.phase === 'candidates' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-neutral-400">Which release is it?</p>
|
||||
{state.candidates.map((c) => (
|
||||
<CandidateCard key={c.id} candidate={c} onSelect={(cand) => dispatch({ type: 'SELECT', candidate: cand })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'confirm' && (
|
||||
<div className="space-y-4">
|
||||
{!state.preview && <p className="py-8 text-center text-neutral-400">Checking release…</p>}
|
||||
{state.preview && (
|
||||
<ConfirmView
|
||||
code={state.code}
|
||||
preview={state.preview}
|
||||
matchAlbumId={state.matchAlbumId}
|
||||
onSetMatch={(albumId) => dispatch({ type: 'SET_MATCH', albumId })}
|
||||
onClearMatch={() => dispatch({ type: 'CLEAR_MATCH' })}
|
||||
onAdd={add}
|
||||
adding={state.adding}
|
||||
addError={state.addError}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'RESET' })}
|
||||
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
|
||||
>
|
||||
Back to search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'added' && (
|
||||
<div className="space-y-4 text-center">
|
||||
<Cover src={state.item.artworkUrl} alt="" className="mx-auto size-32" />
|
||||
<p className="text-lg font-medium">Added to collection ✓</p>
|
||||
<Link className="block text-sm text-emerald-400" to={`/item/${state.item.id}`}>
|
||||
View item
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'RESET' })}
|
||||
className="w-full rounded-xl border border-neutral-700 py-2 text-sm text-neutral-300"
|
||||
>
|
||||
Add another
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'error' && (
|
||||
<div className="space-y-3 py-8 text-center">
|
||||
<p className="text-lg font-medium">
|
||||
{state.kind === 'not_found' ? 'Nothing found' : 'Search failed'}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-400">
|
||||
{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.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'RESET' })}
|
||||
className="rounded-xl border border-neutral-700 px-4 py-2 text-sm text-neutral-300"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
92
web/test/add.test.tsx
Normal file
92
web/test/add.test.tsx
Normal file
@@ -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<typeof import('../src/api.js')>()
|
||||
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(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<AddPage />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
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 }))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user