fix: reset-to-auto in both rip branches, visible mutation failures, distinct invalid-token error
This commit is contained in:
@@ -10,6 +10,7 @@ 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_auth') return 'discogs_auth'
|
||||
if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
|
||||
}
|
||||
return 'server'
|
||||
@@ -179,11 +180,16 @@ export default function AddPage() {
|
||||
{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'}
|
||||
{state.kind === 'not_found'
|
||||
? 'Nothing found'
|
||||
: state.kind === 'discogs_auth'
|
||||
? 'Discogs rejected your token'
|
||||
: '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 === 'discogs_auth' && 'Check your Discogs token in Settings.'}
|
||||
{state.kind === 'rate_limited' && 'Discogs is rate limiting us. Try again shortly.'}
|
||||
</p>
|
||||
<button
|
||||
|
||||
@@ -14,8 +14,10 @@ export default function ItemPage() {
|
||||
const [albums, setAlbums] = useState<DigitalAlbum[] | null>(null)
|
||||
const [pickedAlbum, setPickedAlbum] = useState<number | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState(false)
|
||||
const [mutationError, setMutationError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMutationError(null)
|
||||
void api
|
||||
.getItem(Number(id))
|
||||
.then(setItem)
|
||||
@@ -34,12 +36,20 @@ export default function ItemPage() {
|
||||
|
||||
function applyMatch(albumId: number | null) {
|
||||
if (!item) return
|
||||
void api.setMatch(item.id, albumId).then(setItem)
|
||||
void api
|
||||
.setMatch(item.id, albumId)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
}
|
||||
|
||||
function remove() {
|
||||
if (!item) return
|
||||
void api.deleteItem(item.id).then(() => navigate('/library'))
|
||||
void api.deleteItem(item.id).then(() => navigate('/library')).catch(() =>
|
||||
setMutationError("That didn't work — check your connection and try again.")
|
||||
)
|
||||
}
|
||||
|
||||
if (error) return <p className="py-8 text-center text-sm text-red-400">Item not found.</p>
|
||||
@@ -75,38 +85,65 @@ export default function ItemPage() {
|
||||
{item.ripOverride !== null && ' (manually set)'}
|
||||
</p>
|
||||
) : (
|
||||
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">Not ripped yet</p>
|
||||
<p className="rounded-xl bg-amber-500/10 px-4 py-3 text-sm text-amber-400">
|
||||
Not ripped yet
|
||||
{item.ripOverride !== null && ' (manually set)'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{mutationError && <p className="text-sm text-red-400">{mutationError}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.ripStatus === 'ripped' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.setRip(item.id, false).then(setItem)}
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Mark not ripped
|
||||
</button>
|
||||
{item.ripOverride !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.setRip(item.id, null).then(setItem)}
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Reset to auto
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void api
|
||||
.setRip(item.id, false)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
}
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Mark not ripped
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void api.setRip(item.id, true).then(setItem)}
|
||||
onClick={() =>
|
||||
void api
|
||||
.setRip(item.id, true)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
}
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Mark ripped
|
||||
</button>
|
||||
)}
|
||||
{item.ripOverride !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void api
|
||||
.setRip(item.id, null)
|
||||
.then((updated) => {
|
||||
setItem(updated)
|
||||
setMutationError(null)
|
||||
})
|
||||
.catch(() => setMutationError("That didn't work — check your connection and try again."))
|
||||
}
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Reset to auto
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-neutral-800 bg-neutral-900 p-3">
|
||||
|
||||
@@ -11,6 +11,7 @@ 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_auth') return 'discogs_auth'
|
||||
if (err.code === 'discogs_rate_limited' || err.status === 429) return 'rate_limited'
|
||||
}
|
||||
return 'server'
|
||||
@@ -185,6 +186,16 @@ export default function ScanPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'error' && state.kind === 'discogs_auth' && (
|
||||
<div className="space-y-3 py-8 text-center">
|
||||
<p className="text-lg font-medium">Discogs rejected your token</p>
|
||||
<p className="text-sm text-neutral-400">Check your Discogs token in Settings.</p>
|
||||
<Link to="/settings" className="inline-block rounded-xl bg-emerald-500 px-4 py-2 font-medium text-neutral-950">
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.phase === 'error' && state.kind === 'server' && (
|
||||
<div className="space-y-3 py-8 text-center">
|
||||
<p className="text-lg font-medium">Lookup failed</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Candidate, Item, ReleasePreview } from '../types.js'
|
||||
|
||||
export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'rate_limited' | 'server'
|
||||
export type ScanErrorKind = 'not_found' | 'no_discogs_token' | 'discogs_auth' | 'rate_limited' | 'server'
|
||||
|
||||
export type ScanState =
|
||||
| { phase: 'scan' }
|
||||
|
||||
@@ -127,4 +127,19 @@ describe('ItemPage', () => {
|
||||
const linkBtn = await screen.findByRole('button', { name: /^link$/i })
|
||||
expect(linkBtn).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows Reset to auto for a manual not-ripped override', async () => {
|
||||
vi.mocked(api.getItem).mockResolvedValue({ ...item, ripOverride: false, ripStatus: 'not_ripped' } as never)
|
||||
renderItem()
|
||||
await waitFor(() => expect(screen.getByText(/not ripped yet \(manually set\)/i)).toBeTruthy())
|
||||
expect(screen.getByRole('button', { name: /reset to auto/i })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /mark ripped/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows an error when a rip toggle fails', async () => {
|
||||
vi.mocked(api.setRip).mockRejectedValue(new TypeError('fetch failed'))
|
||||
renderItem()
|
||||
await userEvent.click(await screen.findByRole('button', { name: /mark ripped/i }))
|
||||
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,4 +177,12 @@ describe('ScanPage flow', () => {
|
||||
await waitFor(() => expect(screen.getByText(/added to collection/i)).toBeTruthy())
|
||||
expect(api.addToCollection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('distinguishes an invalid discogs token with a settings link', async () => {
|
||||
vi.mocked(api.lookupBarcode).mockRejectedValue(new ApiError(502, 'discogs_auth'))
|
||||
renderScan()
|
||||
await userEvent.click(screen.getByRole('button', { name: 'fake-scan' }))
|
||||
await waitFor(() => expect(screen.getByText(/rejected your token/i)).toBeTruthy())
|
||||
expect(screen.getByRole('link', { name: /settings/i }).getAttribute('href')).toBe('/settings')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user