Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .superdesign/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,17 @@ DiscWeave is a dense personal music archive for collectors. The UI should stay c
- Standard fields should be offered as curated options.
- Custom field should be an explicit text input with validation hint, not hidden behind a select.
- Compatibility copy must clarify that custom fields are best-effort and format-specific.

## Discogs Release Review Requirements

- Discogs lookup must stay inside the release form, not in a modal.
- Keep the flow dense and review-oriented: search, candidates, selected review, group apply, then normal form save.
- Do not show barcode search fields, barcode values, or raw identifier lists in release lookup/review UI unless a future product task makes them first-class fields.
- Candidate rows should prioritize title, artists, year, labels, formats, catalog number, source link, and Discogs attribution.
- Selecting a candidate must reveal the review immediately near the selected candidate or in a clearly visible review rail; users should not need to discover it by scrolling.
- Review must make apply consequences explicit for Core, Artists, Labels, and Tracklist. External source provenance is applied with the selected Discogs draft after the user applies fields and saves.
- Artist role suggestions that are not already in local dictionaries should be presented as roles that will be added/accepted with the release update, not as unexplained raw Discogs strings.
- Multiple Discogs artist credits must be shown as separate rows grouped under Artists, with each artist name, one or more roles, and a clear existing-role/new-role status. Do not collapse mixed artist roles into a single comma-separated sentence.
- Tracklist review must clearly state whether tracks will be created, replaced, preserved, or updated before the user applies a group.
- Tracklist impact must show representative per-track rows, not only a count. Each row should show position, title, duration when available, track artist credits, and whether those artists/roles are matched, new, or accepted.
- Compilation and Various Artists cases must be explicit: when Discogs track rows have track-specific artists different from release artists, review should show that the release will be treated as Various Artists or that track-level artist credits will be applied. Do not hide compilation behavior behind a generic "Tracklist" checkbox.
106 changes: 72 additions & 34 deletions src/App.auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe('App auth', () => {
])
})

it('opens artist records after login through server search without full catalog hydration', async () => {
it('opens artist records after login through the editable catalog workspace', async () => {
h.clearCatalogForTests()
h.clearAuthSessionForTests()
window.history.pushState({}, '', '/artists')
Expand All @@ -208,7 +208,7 @@ describe('App auth', () => {
email: 'collector@discweave.local',
roles: ['Admin'],
}),
h.emptySearchResponse(),
...h.emptyCatalogLoadResponses(),
)
const user = h.userEvent.setup()
h.render(<h.App />)
Expand All @@ -230,12 +230,8 @@ describe('App auth', () => {

const urls = requestUrls(fetchMock)
expect(urls.slice(0, 2)).toEqual(['/api/auth/session', '/api/auth/login'])
expect(
h
.searchRequestUrls(fetchMock)
.some((url) => url.searchParams.get('entityType') === 'artist'),
).toBe(true)
expect(urls.some((url) => url.startsWith('/api/artists?'))).toBe(false)
expect(urls.some((url) => url.startsWith('/api/artists?'))).toBe(true)
expect(h.searchRequestUrls(fetchMock)).toHaveLength(0)
})

it('shows an accessible error after invalid login', async () => {
Expand Down Expand Up @@ -328,28 +324,43 @@ describe('App auth', () => {
).toBeEnabled()
})

it('shows a server search error for entity workspaces', async () => {
it('shows a catalog load error for entity workspaces', async () => {
h.clearCatalogForTests()
window.history.pushState({}, '', '/tracks')
const fetchMock = h.mockFetch(
h.jsonResponse(
{ code: 'catalog.server_error', message: 'Catalog unavailable' },
500,
),
...h.emptyCatalogLoadResponses().slice(1),
)
const fetchMock = h.vi.fn<Window['fetch']>(async (input) => {
const url = typeof input === 'string' ? input : (input as Request).url
await Promise.resolve()

if (url.startsWith('/api/tracks?')) {
return h.jsonResponse(
{ code: 'catalog.server_error', message: 'Catalog unavailable' },
500,
)
}

if (url.startsWith('/api/settings/dictionaries?')) {
return h.defaultDictionaryListResponse()
}

if (url.startsWith('/api/rating-criteria?')) {
return h.defaultRatingCriteriaListResponse()
}

return h.emptyCatalogListResponse()
})
h.vi.stubGlobal('fetch', fetchMock)

h.render(<h.App />)

expect(
await h.screen.findByRole('heading', { name: 'Tracks' }),
).toBeInTheDocument()
expect(await h.screen.findByRole('alert')).toHaveTextContent(
'Catalog unavailable',
'Catalog request failed. Try again.',
)
expect(
requestUrls(fetchMock).some((url) => url.startsWith('/api/tracks?')),
).toBe(false)
).toBe(true)
})

it('returns to sign in when a catalog mutation expires the session', async () => {
Expand All @@ -359,17 +370,20 @@ describe('App auth', () => {
const url = typeof input === 'string' ? input : (input as Request).url
await Promise.resolve()

if (url.startsWith('/api/search?')) {
return h.emptySearchResponse()
}
if (url === '/api/artists') {
return h.jsonResponse(
{ code: 'auth.unauthenticated', message: 'Session expired' },
401,
)
}
if (url.startsWith('/api/settings/dictionaries?')) {
return h.defaultDictionaryListResponse()
}
if (url.startsWith('/api/rating-criteria?')) {
return h.defaultRatingCriteriaListResponse()
}

return h.emptySearchResponse()
return h.emptyCatalogListResponse()
})
h.vi.stubGlobal('fetch', fetchMock)
const user = h.userEvent.setup()
Expand All @@ -390,19 +404,43 @@ describe('App auth', () => {
it('keeps the loaded workspace available when a catalog refresh fails after a mutation', async () => {
h.clearCatalogForTests()
window.history.pushState({}, '', '/labels')
h.mockFetch(
h.emptySearchResponse(),
...h.emptyCatalogLoadResponses(),
h.jsonResponse({
id: '00000000-0000-7000-8000-000000000010',
name: 'Refresh Failure Label',
let artistListRequests = 0
h.vi.stubGlobal(
'fetch',
h.vi.fn<Window['fetch']>(async (input, init) => {
const url = typeof input === 'string' ? input : (input as Request).url
await Promise.resolve()

if (url === '/api/labels' && init?.method === 'POST') {
return h.jsonResponse({
id: '00000000-0000-7000-8000-000000000010',
name: 'Refresh Failure Label',
})
}

if (url.startsWith('/api/artists?')) {
artistListRequests += 1
if (artistListRequests === 2) {
return h.jsonResponse(
{
code: 'catalog.server_error',
message: 'Catalog refresh failed',
},
500,
)
}
}

if (url.startsWith('/api/settings/dictionaries?')) {
return h.defaultDictionaryListResponse()
}

if (url.startsWith('/api/rating-criteria?')) {
return h.defaultRatingCriteriaListResponse()
}

return h.emptyCatalogListResponse()
}),
h.jsonResponse(
{ code: 'catalog.server_error', message: 'Catalog refresh failed' },
500,
),
...h.emptyCatalogLoadResponses().slice(1),
...h.emptyCatalogLoadResponses(),
)
const user = h.userEvent.setup()
h.render(<h.App />)
Expand Down
97 changes: 97 additions & 0 deletions src/App.catalog-actions.cases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { AppRoutePath } from './app/routes'

export type ManualEntryCase = {
action: string
detailName: string
form: string
heading: string
path: AppRoutePath
requiredLabel: string
rowName: RegExp
searchLabel: string
secondaryRequiredLabel?: string
secondaryValue?: string
value: string
}

export const manualEntryCases: ManualEntryCase[] = [
{
path: '/artists',
heading: 'Artists',
action: 'Add artist',
form: 'Add artist',
requiredLabel: 'Name',
value: 'Coil Archive Test Artist',
searchLabel: 'Search artists',
rowName: /coil archive test artist/i,
detailName: 'Coil Archive Test Artist',
},
{
path: '/releases',
heading: 'Releases',
action: 'Add release',
form: 'Add release',
requiredLabel: 'Title',
value: 'Silent Dub Test Pressing',
searchLabel: 'Search releases',
rowName: /silent dub test pressing/i,
detailName: 'Silent Dub Test Pressing',
},
{
path: '/tracks',
heading: 'Tracks',
action: 'Add track',
form: 'Add track',
requiredLabel: 'Title',
value: 'Unlabeled Field Recording',
searchLabel: 'Search tracks',
rowName: /unlabeled field recording/i,
detailName: 'Unlabeled Field Recording',
},
{
path: '/labels',
heading: 'Labels',
action: 'Add label',
form: 'Add label',
requiredLabel: 'Name',
value: 'Basement White Label',
searchLabel: 'Search labels',
rowName: /basement white label/i,
detailName: 'Basement White Label',
},
{
path: '/owned-items',
heading: 'Owned Items',
action: 'Add owned item',
form: 'Add owned item',
requiredLabel: 'Item name',
value: 'Basement Tape Reference Copy',
searchLabel: 'Search owned items',
rowName: /basement tape reference copy/i,
detailName: 'Basement Tape Reference Copy',
},
{
path: '/relations',
heading: 'Relations',
action: 'Add relation',
form: 'Add relation',
requiredLabel: 'Source',
secondaryRequiredLabel: 'Target',
value: 'Archive Source Person',
secondaryValue: 'Archive Target Project',
searchLabel: 'Search relations',
rowName: /archive source person archive target project/i,
detailName: 'Archive Source Person to Archive Target Project',
},
{
path: '/playlists',
heading: 'Playlists',
action: 'Add playlist',
form: 'Add playlist',
requiredLabel: 'Name',
value: 'Listening Desk Checks',
searchLabel: 'Search playlists',
rowName: /listening desk checks/i,
detailName: 'Listening Desk Checks',
},
]
Loading