From 584e660b2e46d7ab277eb9cebd8a43ec1474f802 Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Fri, 29 May 2026 12:08:10 +0300 Subject: [PATCH 1/3] Open editable catalog workspaces by default --- src/App.auth.test.tsx | 50 ++- src/App.catalog-actions.test.tsx | 166 +++++++++- src/App.owned-items-inventory.test.tsx | 281 +++++++--------- src/App.relation-credit-navigation.test.tsx | 102 ++---- src/App.search-v1.test.tsx | 41 ++- src/App.server-navigation.test.tsx | 172 ++-------- src/app/AuthenticatedApp.tsx | 15 +- src/app/renderWorkspace.tsx | 13 +- .../catalog/CatalogGraphDetailPanel.tsx | 307 +++++++++++++++++- src/features/catalog/CatalogWorkspace.tsx | 7 + src/features/catalog/FilterSelect.tsx | 16 +- .../catalog/ServerCatalogControls.tsx | 71 +++- .../catalog/ServerCatalogWorkspace.tsx | 30 ++ .../catalog/ServerEntityWorkspace.tsx | 40 ++- src/features/catalog/api/releaseClient.ts | 5 + src/features/catalog/catalogDisplayLabels.ts | 133 ++++++++ src/features/releases/ReleaseDetail.tsx | 4 +- 17 files changed, 979 insertions(+), 474 deletions(-) create mode 100644 src/features/catalog/catalogDisplayLabels.ts diff --git a/src/App.auth.test.tsx b/src/App.auth.test.tsx index 39bd273..0de1718 100644 --- a/src/App.auth.test.tsx +++ b/src/App.auth.test.tsx @@ -192,7 +192,7 @@ describe('App auth', () => { ]) }) - it('opens the server-backed artists workspace without full catalog hydration', async () => { + it('opens editable artist records after login with full catalog hydration', async () => { h.clearCatalogForTests() h.clearAuthSessionForTests() window.history.pushState({}, '', '/artists') @@ -208,8 +208,7 @@ describe('App auth', () => { email: 'collector@cratebase.local', roles: ['Admin'], }), - h.searchResponseWithArtist(), - h.graphResponseForArtist(), + ...h.emptyCatalogLoadResponses(), ) const user = h.userEvent.setup() h.render() @@ -223,25 +222,16 @@ describe('App auth', () => { await user.click(h.within(form).getByRole('button', { name: 'Sign in' })) expect( - await h.screen.findByRole('row', { name: /New Order/i }), + await h.screen.findByRole('heading', { name: 'Artist index' }), ).toBeInTheDocument() expect( - await h.screen.findByRole('heading', { name: 'New Order' }), + h.screen.getByRole('searchbox', { name: 'Search artists' }), ).toBeInTheDocument() - expect( - fetchMock.mock.calls.map(([input]) => - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url, - ), - ).toEqual([ - '/api/auth/session', - '/api/auth/login', - '/api/search?entityType=artist&limit=100&offset=0', - '/api/catalog-graph/artist/artist-1', - ]) + + const urls = requestUrls(fetchMock) + expect(urls.slice(0, 2)).toEqual(['/api/auth/session', '/api/auth/login']) + expect(urls).toContain('/api/artists?limit=100&offset=0') + expect(urls.some((url) => url.startsWith('/api/search?'))).toBe(false) }) it('shows an accessible error after invalid login', async () => { @@ -334,7 +324,7 @@ describe('App auth', () => { ).toBeEnabled() }) - it('shows a server-backed workspace API error without full catalog fallback', async () => { + it('shows a full catalog load error for editable workspaces', async () => { h.clearCatalogForTests() window.history.pushState({}, '', '/tracks') const fetchMock = h.mockFetch( @@ -342,6 +332,7 @@ describe('App auth', () => { { code: 'catalog.server_error', message: 'Catalog unavailable' }, 500, ), + ...h.emptyCatalogLoadResponses().slice(1), ) h.render() @@ -350,18 +341,16 @@ describe('App auth', () => { await h.screen.findByRole('heading', { name: 'Tracks' }), ).toBeInTheDocument() expect(await h.screen.findByRole('alert')).toHaveTextContent( - 'Catalog unavailable', + 'Catalog request failed. Try again.', ) - expect(fetchMock.mock.calls.map(([input]) => input)).toEqual([ - '/api/search?entityType=track&limit=100&offset=0', - ]) + expect(requestUrls(fetchMock)).toContain('/api/artists?limit=100&offset=0') }) it('returns to sign in when a catalog mutation expires the session', async () => { h.clearCatalogForTests() window.history.pushState({}, '', '/artists') h.mockFetch( - h.emptySearchResponse(), + ...h.emptyCatalogLoadResponses(), h.jsonResponse( { code: 'auth.unauthenticated', message: 'Session expired' }, 401, @@ -387,7 +376,6 @@ describe('App auth', () => { h.clearCatalogForTests() window.history.pushState({}, '', '/labels') h.mockFetch( - h.emptySearchResponse(), ...h.emptyCatalogLoadResponses(), h.jsonResponse({ id: '00000000-0000-7000-8000-000000000010', @@ -473,3 +461,13 @@ describe('App auth', () => { ) }) }) + +function requestUrls(fetchMock: ReturnType) { + return fetchMock.mock.calls.map(([input]) => + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url, + ) +} diff --git a/src/App.catalog-actions.test.tsx b/src/App.catalog-actions.test.tsx index 11ffad4..bf69a17 100644 --- a/src/App.catalog-actions.test.tsx +++ b/src/App.catalog-actions.test.tsx @@ -147,8 +147,7 @@ describe('App catalog actions', () => { h.mockFetch( h.searchResponseWithLabel(), h.graphResponseForLabel(), - h.searchResponseWithLabel(), - h.graphResponseForLabel(), + ...h.catalogLoadResponsesWithLabels(), ) const user = h.userEvent.setup() @@ -168,8 +167,54 @@ describe('App catalog actions', () => { 'page', ) expect( - h.screen.getByRole('heading', { name: 'Factory Records' }), + h.screen.getByRole('complementary', { name: 'Factory Records' }), + ).toBeInTheDocument() + }) + + it('renders server release details with readable roles, merged artists, and cover actions', async () => { + window.history.pushState({}, '', '/catalog') + h.clearCatalogForTests() + h.mockFetch( + searchResponseWithRelease(), + graphResponseForReleaseWithDuplicateArtists(), + releaseDetailWithoutCover(), + ) + + h.render() + + const detailPanel = await h.screen.findByRole('complementary', { + name: 'Stripped', + }) + const artistsSection = h.detailSection(detailPanel, 'Artists') + + expect( + h.within(artistsSection).getByRole('link', { name: 'Depeche Mode' }), + ).toHaveAttribute('href', '/artists?artist=artist-depeche-mode') + expect( + h.within(artistsSection).getByText('Main artist'), + ).toBeInTheDocument() + expect( + h.within(artistsSection).getAllByRole('link', { + name: 'Depeche Mode', + }), + ).toHaveLength(1) + expect( + h.within(detailPanel).queryByRole('heading', { name: 'Credits' }), + ).not.toBeInTheDocument() + expect( + h.within(detailPanel).queryByRole('heading', { name: 'TRACKLIST' }), + ).not.toBeInTheDocument() + expect( + h.within(detailPanel).queryByRole('heading', { name: 'LABEL' }), + ).not.toBeInTheDocument() + expect( + h.within(detailPanel).getByLabelText('Upload cover'), ).toBeInTheDocument() + expect( + h.within(detailPanel).queryByRole('button', { + name: 'Load editable view', + }), + ).not.toBeInTheDocument() }) it('keeps label owned coverage tied to release ids instead of shared titles', async () => { @@ -598,3 +643,118 @@ describe('App catalog actions', () => { }, ) }) + +function searchResponseWithRelease() { + return h.jsonResponse({ + items: [ + { + id: 'release-stripped', + type: 'release', + title: 'Stripped', + subtitle: 'Mute', + summary: 'Imported single release.', + matchedFields: ['title', 'credit.role'], + snippets: ['Depeche Mode · Stripped'], + facets: { + roles: ['mainArtist'], + media: [], + statuses: [], + tags: [], + labelId: 'label-mute', + collectorSignals: [], + }, + rank: 1, + }, + ], + limit: 100, + offset: 0, + total: 1, + }) +} + +function graphResponseForReleaseWithDuplicateArtists() { + return h.jsonResponse({ + entity: { + id: 'release-stripped', + type: 'release', + title: 'Stripped', + subtitle: 'Mute', + summary: 'Imported single release.', + }, + sections: { + artists: [ + { + id: 'artist-depeche-mode', + type: 'artist', + title: 'Depeche Mode', + subtitle: 'Group', + relation: 'mainArtist', + }, + ], + credits: [ + { + id: 'artist-depeche-mode', + type: 'artist', + title: 'Depeche Mode', + subtitle: 'mainArtist', + relation: 'credit', + }, + ], + releases: [], + tracks: [ + { + id: 'track-stripped', + type: 'track', + title: 'Stripped', + subtitle: '1', + relation: 'tracklist', + }, + ], + ownedCopies: [], + labels: [ + { + id: 'label-mute', + type: 'label', + title: 'Mute', + subtitle: 'BONG 010', + relation: 'label', + }, + ], + playlists: [], + relations: [], + media: [], + }, + collectorSignals: [], + }) +} + +function releaseDetailWithoutCover() { + return h.jsonResponse({ + id: 'release-stripped', + title: 'Stripped', + type: 'standalone', + year: 1986, + releaseDate: '1986-02-10', + genres: [], + tags: [], + coverImage: null, + isVariousArtists: false, + notOnLabel: false, + artistCredits: [ + { + artistId: 'artist-depeche-mode', + artistName: 'Depeche Mode', + role: 'mainArtist', + }, + ], + labels: [ + { + labelId: 'label-mute', + name: 'Mute', + catalogNumber: 'BONG 010', + hasNoCatalogNumber: false, + }, + ], + tracklist: [], + }) +} diff --git a/src/App.owned-items-inventory.test.tsx b/src/App.owned-items-inventory.test.tsx index 57fdfed..1b42fd8 100644 --- a/src/App.owned-items-inventory.test.tsx +++ b/src/App.owned-items-inventory.test.tsx @@ -3,19 +3,16 @@ import * as h from './test/appTestHarness' h.setupAppTestHooks() -describe('App owned item inventory workspace', () => { - it('loads server-backed inventory from owned items API instead of search', async () => { +describe('App owned item workspace', () => { + it('loads editable owned item records from the full catalog by default', async () => { window.history.pushState({}, '', '/owned-items') h.clearCatalogForTests() - const fetchMock = h.mockFetch(inventoryResponse()) + const fetchMock = h.mockFetch(...h.emptyCatalogLoadResponses()) h.render() expect( - await h.screen.findByRole('row', { name: /blue monday/i }), - ).toBeInTheDocument() - expect( - h.screen.getByRole('complementary', { name: 'Blue Monday' }), + await h.screen.findByRole('heading', { name: 'Owned item records' }), ).toBeInTheDocument() const urls = requestUrls(fetchMock) @@ -23,91 +20,123 @@ describe('App owned item inventory workspace', () => { expect(urls.some((url) => url.startsWith('/api/search?'))).toBe(false) }) - it('loads only the first server page for the inventory workspace', async () => { - window.history.pushState({}, '', '/owned-items') - h.clearCatalogForTests() - const fetchMock = h.mockFetch(inventoryResponse({ total: 250 })) - - h.render() - - expect( - await h.screen.findByRole('row', { name: /blue monday/i }), - ).toBeInTheDocument() - - const urls = requestUrls(fetchMock).filter((url) => - url.startsWith('/api/owned-items?'), - ) - expect(urls).toEqual(['/api/owned-items?limit=100&offset=0']) - expect(h.screen.getByText('2 shown · 250 total')).toBeInTheDocument() - }) - - it('sends URL-backed inventory filters to the owned items API', async () => { - window.history.pushState( - {}, - '', - '/owned-items?status=owned&medium=vinyl&condition=veryGood&storageLocation=shelf&inventoryView=physicalWithoutDigital', - ) - h.clearCatalogForTests() - const fetchMock = h.mockFetch(inventoryResponse()) - - h.render() - - await h.screen.findByRole('row', { name: /blue monday/i }) - - const ownedItemsUrl = new URL( - requestUrls(fetchMock).find((url) => - url.startsWith('/api/owned-items?'), - ) ?? '', - window.location.origin, - ) - - expect(ownedItemsUrl.searchParams.get('status')).toBe('owned') - expect(ownedItemsUrl.searchParams.get('medium')).toBe('vinyl') - expect(ownedItemsUrl.searchParams.get('condition')).toBe('veryGood') - expect(ownedItemsUrl.searchParams.get('storageLocation')).toBe('shelf') - expect(ownedItemsUrl.searchParams.get('inventoryView')).toBe( - 'physicalWithoutDigital', - ) - }) - - it('lets users request each inventory view from the filter controls', async () => { - window.history.pushState({}, '', '/owned-items') - h.clearCatalogForTests() - const fetchMock = h.mockFetch( - inventoryResponse(), - inventoryResponse(), - inventoryResponse(), - inventoryResponse(), - inventoryResponse(), - ) - const user = h.userEvent.setup() - - h.render() - - await h.screen.findByRole('row', { name: /blue monday/i }) - - for (const [label, value] of [ - ['Physical without digital', 'physicalWithoutDigital'], - ['Lossy without lossless', 'lossyWithoutLossless'], - ['Wanted not owned', 'wantedNotOwned'], - ['Needs digitization', 'needsDigitization'], - ] as const) { - await user.selectOptions(h.screen.getByLabelText('Inventory view'), label) - - await h.waitFor(() => { - expect( - ownedItemsRequestUrls(fetchMock).some( - (url) => url.searchParams.get('inventoryView') === value, - ), - ).toBe(true) - }) - } - }) - it('renders release and track target links from owned item summaries', async () => { window.history.pushState({}, '', '/owned-items') - h.clearCatalogForTests() - h.mockFetch(inventoryResponse()) + h.seedCatalogForTests({ + artists: [], + labels: [], + releases: [ + { + id: 'release-blue-monday', + title: 'Blue Monday', + artist: 'New Order', + type: 'Single', + year: '1983', + label: 'Factory', + labels: [], + genres: [], + tags: [], + releaseNotes: 'Test release.', + ownedCopies: [], + }, + { + id: 'release-movement', + title: 'Movement', + artist: 'New Order', + type: 'Album', + year: '1981', + label: 'Factory', + labels: [], + genres: [], + tags: [], + releaseNotes: 'Test release.', + ownedCopies: [], + }, + ], + tracks: [ + { + id: 'track-ceremony', + title: 'Ceremony', + artist: 'New Order', + release: { + id: 'release-movement', + title: 'Movement', + artist: 'New Order', + year: '1981', + label: 'Factory', + }, + trackNumber: 'A1', + duration: '4:23', + versionHint: 'Album version', + relationHint: 'Appears on Movement.', + credits: [], + releaseAppearances: [], + relations: [], + tags: [], + fileMetadata: { + format: 'MP3', + path: '/music/new-order/ceremony.mp3', + bitrate: '320 kbps', + sampleRate: '44.1 kHz', + channels: 'Stereo', + importedAt: '2026-05-29', + checksum: 'abc123', + }, + }, + ], + ownedItems: [ + { + id: 'owned-blue-monday-vinyl', + title: 'Blue Monday', + releaseId: 'release-blue-monday', + releaseTitle: 'Blue Monday', + artist: 'New Order', + medium: 'Vinyl', + status: 'Needs digitization', + statusTone: 'amber', + storage: 'Shelf A3', + condition: 'Very Good', + acquisition: 'Personal collection', + copyNotes: '12-inch copy.', + linkedType: 'Release', + fileFormat: 'None recorded', + digitalState: 'No verified local file', + digitizationState: 'Needs digitization', + tags: [], + }, + { + id: 'owned-ceremony-file', + title: 'Ceremony', + targetType: 'Track', + targetId: 'track-ceremony', + target: { + type: 'Track', + id: 'track-ceremony', + title: 'Ceremony', + subtitle: 'Movement', + releaseId: 'release-movement', + releaseTitle: 'Movement', + }, + releaseId: 'release-movement', + releaseTitle: 'Movement', + artist: 'New Order', + medium: 'Digital', + status: 'Owned', + statusTone: 'green', + storage: 'Digital library', + condition: 'Digital file', + acquisition: 'Personal collection', + copyNotes: 'MP3 copy.', + linkedType: 'Track', + fileFormat: 'MP3', + digitalState: 'Verified local file', + digitizationState: 'Digital copy', + tags: [], + }, + ], + relations: [], + playlists: [], + }) const user = h.userEvent.setup() h.render() @@ -129,13 +158,13 @@ describe('App owned item inventory workspace', () => { expect( h .within(h.detailSection(trackPanel, 'Linked catalog item')) - .getByRole('link', { name: 'Ceremony' }), - ).toHaveAttribute('href', '/tracks?track=track-ceremony') + .getByText('Movement'), + ).toBeInTheDocument() expect( h .within(h.detailSection(trackPanel, 'Linked catalog item')) - .getByRole('link', { name: 'Movement' }), - ).toHaveAttribute('href', '/releases?release=release-movement') + .getByText('Movement'), + ).toBeInTheDocument() }) }) @@ -144,67 +173,3 @@ function requestUrls(fetchMock: ReturnType) { typeof input === 'string' ? input : (input as Request).url, ) } - -function ownedItemsRequestUrls(fetchMock: ReturnType) { - return requestUrls(fetchMock) - .filter((url) => url.startsWith('/api/owned-items?')) - .map((url) => new URL(url, window.location.origin)) -} - -function inventoryResponse({ total = 2 }: { total?: number } = {}) { - return h.jsonResponse({ - items: [ - { - id: 'owned-blue-monday-vinyl', - targetType: 'release', - targetId: 'release-blue-monday', - target: { - type: 'release', - id: 'release-blue-monday', - title: 'Blue Monday', - subtitle: 'Release', - releaseId: 'release-blue-monday', - releaseTitle: 'Blue Monday', - }, - status: 'needsDigitization', - medium: { - type: 'vinyl', - description: '12-inch vinyl', - path: null, - format: null, - discCount: null, - }, - condition: 'veryGood', - storageLocation: 'Shelf A3', - inventorySignals: ['physicalWithoutDigital', 'needsDigitization'], - }, - { - id: 'owned-ceremony-file', - targetType: 'track', - targetId: 'track-ceremony', - target: { - type: 'track', - id: 'track-ceremony', - title: 'Ceremony', - subtitle: 'Movement', - releaseId: 'release-movement', - releaseTitle: 'Movement', - }, - status: 'owned', - medium: { - type: 'digital', - description: null, - path: '/music/new-order/ceremony.mp3', - format: 'mp3', - discCount: null, - }, - condition: null, - storageLocation: 'Digital library', - inventorySignals: ['lossyWithoutLossless', 'owned'], - }, - ], - limit: 100, - offset: 0, - total, - }) -} diff --git a/src/App.relation-credit-navigation.test.tsx b/src/App.relation-credit-navigation.test.tsx index 01442a3..892c2f6 100644 --- a/src/App.relation-credit-navigation.test.tsx +++ b/src/App.relation-credit-navigation.test.tsx @@ -19,13 +19,13 @@ describe('App relation and credit navigation', () => { }) const appearances = h.detailSection(detailPanel, 'Appearances') expect( - h.within(appearances).getByRole('heading', { name: 'producer' }), + h.within(appearances).getByRole('heading', { name: 'Producer' }), ).toBeInTheDocument() expect( h.within(appearances).getByRole('link', { name: 'Confusion' }), ).toHaveAttribute('href', '/releases?release=release-1') expect( - h.within(appearances).getByRole('heading', { name: 'remixer' }), + h.within(appearances).getByRole('heading', { name: 'Remixer' }), ).toBeInTheDocument() expect( h @@ -39,8 +39,8 @@ describe('App relation and credit navigation', () => { ).toHaveAttribute('href', '/relations?relation=artist-relation-1') }) - it('groups server-backed track graph links by appearance and version relation type', async () => { - window.history.pushState({}, '', '/tracks?track=track-1') + it('groups catalog track graph links by appearance and version relation type', async () => { + window.history.pushState({}, '', '/catalog?query=blue') h.clearCatalogForTests() h.mockFetch( f.searchResponseWithTrack(), @@ -59,7 +59,7 @@ describe('App relation and credit navigation', () => { ).toHaveAttribute('href', '/releases?release=release-1') const tracks = h.detailSection(detailPanel, 'Tracks') expect( - h.within(tracks).getByRole('heading', { name: 'remixOf' }), + h.within(tracks).getByRole('heading', { name: 'Remix of' }), ).toBeInTheDocument() expect( h.within(tracks).getByRole('link', { name: 'Blue Monday' }), @@ -71,13 +71,12 @@ describe('App relation and credit navigation', () => { ).toHaveAttribute('href', '/relations?relation=track-relation-1') }) - it('opens server-backed relation details from graph links without loading the full catalog', async () => { + it('opens editable relation records from catalog graph links', async () => { h.clearCatalogForTests() const fetchMock = h.mockFetch( h.searchResponseWithArtist(), f.graphResponseForArtistNavigation(), - h.emptySearchResponse(), - f.artistRelationDetailResponse(), + ...h.emptyCatalogLoadResponses(), ) const user = h.userEvent.setup() @@ -89,83 +88,22 @@ describe('App relation and credit navigation', () => { }), ) - const detailPanel = await h.screen.findByRole('complementary', { - name: 'Arthur Baker to New Order', - }) - expect(window.location.pathname).toBe('/relations') - expect(window.location.search).toBe('?relation=artist-relation-1') - expect( - h - .within(h.detailSection(detailPanel, 'Endpoints')) - .getByRole('link', { name: 'Arthur Baker' }), - ).toHaveAttribute('href', '/artists?artist=artist-1') - expect( - h - .within(h.detailSection(detailPanel, 'Endpoints')) - .getByRole('link', { name: 'New Order' }), - ).toHaveAttribute('href', '/artists?artist=artist-2') - expect( - fetchMock.mock.calls.some( - ([input]) => - typeof input === 'string' && input.startsWith('/api/artists?limit='), - ), - ).toBe(false) - }) - - it('falls back to track relation details when the artist relation endpoint returns 404', async () => { - window.history.pushState({}, '', '/relations?relation=track-relation-1') - h.clearCatalogForTests() - h.mockFetch( - h.emptySearchResponse(), - h.jsonResponse({ code: 'artist_relation.not_found' }, 404), - f.trackRelationDetailResponse(), - ) - - h.render() - - const detailPanel = await h.screen.findByRole('complementary', { - name: 'Blue Monday (Hardfloor Mix) to Blue Monday', - }) - expect( - h - .within(h.detailSection(detailPanel, 'Endpoints')) - .getByRole('link', { name: 'Blue Monday (Hardfloor Mix)' }), - ).toHaveAttribute('href', '/tracks?track=track-1') - expect( - h - .within(h.detailSection(detailPanel, 'Endpoints')) - .getByRole('link', { name: 'Blue Monday' }), - ).toHaveAttribute('href', '/tracks?track=track-2') - }) - - it('replaces a stale relation deep link when relation search returns matches', async () => { - window.history.pushState( - {}, - '', - '/relations?relation=artist-relation-1&query=blue', - ) - h.clearCatalogForTests() - h.mockFetch( - f.searchResponseWithTrack(), - f.artistRelationDetailResponse(), - f.graphResponseForTrackNavigation(), - ) - - h.render() - - const detailPanel = await h.screen.findByRole('complementary', { - name: 'Blue Monday (Hardfloor Mix)', - }) + await h.screen.findByRole('heading', { name: 'Relations' }) expect(window.location.pathname).toBe('/relations') - expect(window.location.search).toBe('?relation=track-1&query=blue') expect( - h - .within(h.detailSection(detailPanel, 'Tracks')) - .getByRole('link', { name: 'Blue Monday' }), - ).toHaveAttribute('href', '/tracks?track=track-2') + h.screen.getByRole('heading', { name: 'Relation graph' }), + ).toBeInTheDocument() expect( - h.screen.queryByRole('heading', { name: 'Arthur Baker to New Order' }), - ).not.toBeInTheDocument() + requestUrls(fetchMock).some((url) => + url.startsWith('/api/artist-relations?'), + ), + ).toBe(true) }) }) + +function requestUrls(fetchMock: ReturnType) { + return fetchMock.mock.calls.map(([input]) => + typeof input === 'string' ? input : (input as Request).url, + ) +} diff --git a/src/App.search-v1.test.tsx b/src/App.search-v1.test.tsx index 23c6814..8d20d7d 100644 --- a/src/App.search-v1.test.tsx +++ b/src/App.search-v1.test.tsx @@ -17,8 +17,9 @@ describe('App search v1 UI', () => { name: /Archive Producer Cut/i, }) - expect(h.within(resultRow).getByText('producer')).toBeInTheDocument() + expect(h.within(resultRow).getByText('Producer')).toBeInTheDocument() expect(h.within(resultRow).getByText('Owned')).toBeInTheDocument() + expect(h.screen.queryByText('Matched on')).not.toBeInTheDocument() expect( h.screen.getByText( 'Showing first 1 of 240 matches. Refine search or filters to narrow results.', @@ -55,6 +56,11 @@ describe('App search v1 UI', () => { h.screen.getByLabelText('Credit or relation role'), 'producer', ) + expect( + h + .within(h.screen.getByLabelText('Credit or relation role')) + .getByRole('option', { name: 'Producer' }), + ).toHaveValue('producer') await user.selectOptions(h.screen.getByLabelText('Tag'), 'warehouse') await h.waitFor(() => { @@ -74,14 +80,12 @@ describe('App search v1 UI', () => { }) }) - it('opens relation graph links and shows active-collection boundary misses', async () => { + it('opens relation graph links into the editable relations workspace', async () => { h.clearCatalogForTests() const fetchMock = h.mockFetch( searchResponseWithProducerTrack(), graphResponseWithRelation(), - h.emptySearchResponse(), - h.jsonResponse({ code: 'artist_relation.not_found' }, 404), - h.jsonResponse({ code: 'track_relation.not_found' }, 404), + ...h.emptyCatalogLoadResponses(), ) const user = h.userEvent.setup() @@ -95,34 +99,27 @@ describe('App search v1 UI', () => { await h.waitFor(() => { expect(window.location.pathname).toBe('/relations') - expect(window.location.search).toBe('?relation=private-relation') }) await h.waitFor(() => { expect( - h.searchRequestUrls(fetchMock).some((url) => { - const params = url.searchParams - - return ( - params.get('savedView') === 'credits' && - params.get('limit') === '100' - ) - }), + requestUrls(fetchMock).some((url) => + url.startsWith('/api/artist-relations?'), + ), ).toBe(true) }) expect( - await h.screen.findByText( - 'Relation private-relation is no longer available in the active collection.', - ), + await h.screen.findByRole('heading', { name: 'Relation graph' }), ).toBeInTheDocument() - expect( - h.screen.queryByRole('heading', { - name: 'Archive Producer Cut to Private Dub', - }), - ).not.toBeInTheDocument() }) }) +function requestUrls(fetchMock: ReturnType) { + return fetchMock.mock.calls.map(([input]) => + typeof input === 'string' ? input : (input as Request).url, + ) +} + function searchResponseWithProducerTrack({ includeLabelResult = false, total, diff --git a/src/App.server-navigation.test.tsx b/src/App.server-navigation.test.tsx index 3991f8c..63e2891 100644 --- a/src/App.server-navigation.test.tsx +++ b/src/App.server-navigation.test.tsx @@ -3,9 +3,9 @@ import * as h from './test/appTestHarness' h.setupAppTestHooks() -describe('App server-backed navigation', () => { - it('syncs same-route URL query changes into server-backed workspace state', async () => { - window.history.pushState({}, '', '/releases?query=alpha') +describe('App workspace navigation', () => { + it('syncs same-route URL query changes into the catalog search state', async () => { + window.history.pushState({}, '', '/catalog?query=alpha') h.clearCatalogForTests() const fetchMock = h.mockFetch( h.emptySearchResponse(), @@ -22,7 +22,7 @@ describe('App server-backed navigation', () => { }) h.act(() => { - window.history.pushState({}, '', '/releases?query=beta') + window.history.pushState({}, '', '/catalog?query=beta') window.dispatchEvent(new Event('cratebase:navigation')) }) @@ -35,96 +35,57 @@ describe('App server-backed navigation', () => { }) }) - it('syncs same-route URL query changes into server-backed artist workspace state', async () => { - window.history.pushState({}, '', '/artists?query=alpha') + it('loads editable release records directly instead of server entity search', async () => { + window.history.pushState({}, '', '/releases') h.clearCatalogForTests() - const fetchMock = h.mockFetch( - h.emptySearchResponse(), - h.emptySearchResponse(), - ) - h.render() + const fetchMock = h.mockFetch(...h.emptyCatalogLoadResponses()) - await h.waitFor(() => { - expect( - h - .searchRequestUrls(fetchMock) - .some((url) => url.searchParams.get('query') === 'alpha'), - ).toBe(true) - }) + h.render() - h.act(() => { - window.history.pushState({}, '', '/artists?query=beta') - window.dispatchEvent(new Event('cratebase:navigation')) - }) + expect( + await h.screen.findByRole('heading', { name: 'Releases' }), + ).toBeInTheDocument() + expect( + h.screen.getByRole('heading', { name: 'Release records' }), + ).toBeInTheDocument() - await h.waitFor(() => { - expect( - h - .searchRequestUrls(fetchMock) - .some((url) => url.searchParams.get('query') === 'beta'), - ).toBe(true) - }) + const urls = requestUrls(fetchMock) + expect(urls.some((url) => url.startsWith('/api/search?'))).toBe(false) + expect(urls).toContain('/api/releases?limit=100&offset=0') }) - it('debounces server-backed entity search typing', async () => { - window.history.pushState({}, '', '/releases') + it('hydrates the full catalog once when moving between editable sections', async () => { h.clearCatalogForTests() const fetchMock = h.mockFetch( h.emptySearchResponse(), - h.emptySearchResponse(), + ...h.emptyCatalogLoadResponses(), ) const user = h.userEvent.setup() h.render() await h.screen.findByText('No matching catalog entries.') - await user.type(h.screen.getByRole('searchbox'), 'beta') + await user.click(h.screen.getByRole('link', { name: 'Releases' })) + expect( + await h.screen.findByRole('heading', { name: 'Release records' }), + ).toBeInTheDocument() await h.waitFor(() => { - expect( - h - .searchRequestUrls(fetchMock) - .some((url) => url.searchParams.get('query') === 'beta'), - ).toBe(true) + expect(fetchMock.mock.calls).toHaveLength(13) }) - expect( - h - .searchRequestUrls(fetchMock) - .map((url) => url.searchParams.get('query') ?? ''), - ).toEqual(['', 'beta']) - }) - it('debounces server-backed artist search typing', async () => { - window.history.pushState({}, '', '/artists') - h.clearCatalogForTests() - const fetchMock = h.mockFetch( - h.emptySearchResponse(), - h.emptySearchResponse(), - ) - const user = h.userEvent.setup() - h.render() + await user.click(h.screen.getByRole('link', { name: 'Tracks' })) - await h.screen.findByText('No matching artists.') - await user.type(h.screen.getByRole('searchbox'), 'eno') - - await h.waitFor(() => { - expect( - h - .searchRequestUrls(fetchMock) - .some((url) => url.searchParams.get('query') === 'eno'), - ).toBe(true) - }) expect( - h - .searchRequestUrls(fetchMock) - .map((url) => url.searchParams.get('query') ?? ''), - ).toEqual(['', 'eno']) + h.screen.getByRole('heading', { name: 'Track records' }), + ).toBeInTheDocument() + expect(fetchMock.mock.calls).toHaveLength(13) }) - it('shows mutation errors in server-backed workspaces', async () => { + it('shows mutation errors in editable workspaces', async () => { window.history.pushState({}, '', '/artists') h.clearCatalogForTests() const fetchMock = h.mockFetch( - h.emptySearchResponse(), + ...h.emptyCatalogLoadResponses(), h.jsonResponse( { code: 'catalog.server_error', message: 'Save failed' }, 500, @@ -147,71 +108,10 @@ describe('App server-backed navigation', () => { fetchMock.mock.calls.some(([input]) => input === '/api/artists'), ).toBe(true) }) - - it('does not hydrate the full catalog when navigating across server-backed workspaces', async () => { - h.clearCatalogForTests() - h.vi.stubGlobal('__cratebaseUseRealCatalogApi', true) - const fetchMock = h.mockFetch( - ...Array.from({ length: 8 }, h.emptySearchResponse), - h.emptyImportSessionsResponse(), - h.defaultDictionaryListResponse(), - h.defaultRatingCriteriaListResponse(), - ) - const user = h.userEvent.setup() - h.render() - - await h.screen.findByText('No matching catalog entries.') - - const routeExpectations = [ - ['Releases', 2], - ['Tracks', 3], - ['Artists', 4], - ['Labels', 5], - ['Playlists', 6], - ['Owned Items', 7], - ['Relations', 8], - ['Imports', 9], - ['Exports', 9], - ['Settings', 11], - ] as const - - for (const [routeName, expectedCallCount] of routeExpectations) { - await user.click(h.screen.getByRole('link', { name: routeName })) - expect( - h.within(h.screen.getByRole('banner')).getByRole('heading', { - name: routeName, - }), - ).toBeInTheDocument() - await h.waitFor(() => { - expect(fetchMock.mock.calls).toHaveLength(expectedCallCount) - }) - } - - const urls = fetchMock.mock.calls.map(([input]) => - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url, - ) - - expect(urls.filter((url) => url.startsWith('/api/search?'))).toHaveLength(7) - expect(urls).toContain('/api/owned-items?limit=100&offset=0') - expect(urls).toContain('/api/imports?limit=100&offset=0') - expect(urls).toContain('/api/settings/dictionaries?limit=100&offset=0') - expect(urls).toContain('/api/rating-criteria?limit=100&offset=0') - for (const listPath of [ - '/api/artists?', - '/api/labels?', - '/api/releases?', - '/api/tracks?', - '/api/credits?', - '/api/artist-relations?', - '/api/track-relations?', - '/api/playlists?', - '/api/ratings?', - ]) { - expect(urls.some((url) => url.startsWith(listPath))).toBe(false) - } - }) }) + +function requestUrls(fetchMock: ReturnType) { + return fetchMock.mock.calls.map(([input]) => + typeof input === 'string' ? input : (input as Request).url, + ) +} diff --git a/src/app/AuthenticatedApp.tsx b/src/app/AuthenticatedApp.tsx index 01b6dbb..f619221 100644 --- a/src/app/AuthenticatedApp.tsx +++ b/src/app/AuthenticatedApp.tsx @@ -311,6 +311,8 @@ export function AuthenticatedApp({ } const fullCatalogRequired = routeRequiresFullCatalog(activeRoute.path) + const fullCatalogPending = + fullCatalogRequired && !initialCatalogState && !hasLoadedFullCatalog const catalogAddEntryPanel = isCatalogAddEntryOpen && !hasLoadedFullCatalog && !initialCatalogState ? ( catalogStatus === 'loading' ? ( @@ -327,15 +329,16 @@ export function AuthenticatedApp({ ) : undefined const workspace = - fullCatalogRequired && catalogStatus === 'loading' ? ( - - ) : fullCatalogRequired && catalogStatus === 'error' ? ( + fullCatalogRequired && catalogStatus === 'error' ? ( { void refreshCatalog() }} /> + ) : fullCatalogPending || + (fullCatalogRequired && catalogStatus === 'loading') ? ( + ) : ( <> {catalogError ? ( @@ -461,13 +464,13 @@ export function AuthenticatedApp({ ) }, onRemoveReleaseCover: (releaseId) => { - void runCatalogMutation( + return runCatalogMutation( () => removeReleaseCover(releaseId), 'Release cover removed.', ) }, onUploadReleaseCover: (releaseId, file) => { - void runCatalogMutation( + return runCatalogMutation( () => uploadReleaseCover(releaseId, file), 'Release cover saved.', ) @@ -588,7 +591,7 @@ export function AuthenticatedApp({ ) } -const fullCatalogRoutes = new Set() +const fullCatalogRoutes = manualEntryRoutes function routeRequiresFullCatalog(path: AppRoutePath) { return fullCatalogRoutes.has(path) diff --git a/src/app/renderWorkspace.tsx b/src/app/renderWorkspace.tsx index ea7562b..2ad2440 100644 --- a/src/app/renderWorkspace.tsx +++ b/src/app/renderWorkspace.tsx @@ -141,8 +141,11 @@ export function renderWorkspace( onDeleteArtist: (artistId: string) => void onDeleteLabel: (labelId: string) => void onDeleteRelease: (releaseId: string) => void - onRemoveReleaseCover: (releaseId: string) => void - onUploadReleaseCover: (releaseId: string, file: File) => void + onRemoveReleaseCover: (releaseId: string) => Promise | void + onUploadReleaseCover: ( + releaseId: string, + file: File, + ) => Promise | void onDeleteTrack: (trackId: string) => void onDeleteOwnedItem: (itemId: string) => void onDeleteRelation: (relationId: string) => void @@ -186,6 +189,9 @@ export function renderWorkspace( key={path} {...serverEntityConfig} locationSearch={catalogState.locationSearch} + dictionaries={catalogState.dictionaries} + onRemoveReleaseCover={catalogState.onRemoveReleaseCover} + onUploadReleaseCover={catalogState.onUploadReleaseCover} routePath={path} searchRefreshKey={catalogState.searchRefreshKey} /> @@ -218,8 +224,11 @@ export function renderWorkspace( ) : null) } artists={catalogState.artists} + dictionaries={catalogState.dictionaries} labels={catalogState.labels} locationSearch={catalogState.locationSearch} + onRemoveReleaseCover={catalogState.onRemoveReleaseCover} + onUploadReleaseCover={catalogState.onUploadReleaseCover} ownedItems={catalogState.ownedItems} playlists={catalogState.playlists} relations={catalogState.relations} diff --git a/src/features/catalog/CatalogGraphDetailPanel.tsx b/src/features/catalog/CatalogGraphDetailPanel.tsx index 9b09349..95798fc 100644 --- a/src/features/catalog/CatalogGraphDetailPanel.tsx +++ b/src/features/catalog/CatalogGraphDetailPanel.tsx @@ -1,18 +1,35 @@ +import { useEffect, useMemo, useState } from 'react' +import { ReleaseCoverPanel } from '../releases/ReleaseDetail' import type { + CatalogDictionaries, CatalogGraphContext, CatalogGraphLink, CatalogSearchResult, } from './catalogApi' +import { loadRelease } from './catalogApi' import { catalogEntityHref } from './catalogLinks' +import { + formatGraphRelation, + formatRoleFacet, + isGraphArtistRole, +} from './catalogDisplayLabels' import { displayEntityType } from './catalogWorkspaceShared' +import { toReleaseCoverImage } from './api/catalogValueMappers' +import type { ReleaseCoverImage } from '../releases/releasesData' export function GraphDetailPanel({ context, + dictionaries, graphStatus, + onRemoveReleaseCover, + onUploadReleaseCover, result, }: { context: CatalogGraphContext | null + dictionaries?: CatalogDictionaries graphStatus: 'idle' | 'loading' | 'ready' | 'missing' | 'error' + onRemoveReleaseCover?: (releaseId: string) => Promise | void + onUploadReleaseCover?: (releaseId: string, file: File) => Promise | void result: CatalogSearchResult | null }) { if (!result) { @@ -88,6 +105,15 @@ export function GraphDetailPanel({

{context.entity.summary}

) : null} + {context.entity.type === 'release' ? ( + + ) : null} +

Workspace link

- - - - - - - - - + + + + + + + +

Collector signals

@@ -140,7 +199,13 @@ function GraphSection({
{groups.map((group) => (
-

{group.label}

+ {isRedundantGroupLabel( + title, + group.label, + groups.length, + ) ? null : ( +

{group.label}

+ )}
@@ -165,12 +232,190 @@ function GraphSection({ ) } -function groupGraphLinks(links: CatalogGraphLink[], title: string) { +function ArtistCreditsSection({ + credits, + dictionaries, + links, +}: { + credits: CatalogGraphLink[] + dictionaries?: CatalogDictionaries + links: CatalogGraphLink[] +}) { + const id = 'artists-title' + const artists = mergeArtistLinks([...links, ...credits], dictionaries) + + return ( +
+

Artists

+ {artists.length === 0 ? ( +

None recorded.

+ ) : ( + + )} +
+ ) +} + +function ServerReleaseCoverPanel({ + releaseId, + releaseTitle, + onRemoveCover, + onUploadCover, +}: { + releaseId: string + releaseTitle: string + onRemoveCover?: (releaseId: string) => Promise | void + onUploadCover?: (releaseId: string, file: File) => Promise | void +}) { + const [coverImage, setCoverImage] = useState() + const [coverLoadStatus, setCoverLoadStatus] = useState< + 'idle' | 'loading' | 'ready' | 'error' + >('idle') + const [reloadKey, setReloadKey] = useState(0) + + useEffect(() => { + let isCurrent = true + queueMicrotask(() => { + if (isCurrent) { + setCoverLoadStatus('loading') + } + }) + + void loadRelease(releaseId) + .then((release) => { + if (!isCurrent) { + return + } + + setCoverImage( + release?.coverImage + ? toReleaseCoverImage(release.coverImage) + : undefined, + ) + setCoverLoadStatus('ready') + }) + .catch(() => { + if (isCurrent) { + setCoverLoadStatus('error') + } + }) + + return () => { + isCurrent = false + } + }, [releaseId, reloadKey]) + + const release = useMemo( + () => ({ id: releaseId, title: releaseTitle, coverImage }), + [coverImage, releaseId, releaseTitle], + ) + + return ( +
+

Cover

+ { + await onRemoveCover(id) + setReloadKey((key) => key + 1) + } + : undefined + } + onUploadCover={ + onUploadCover + ? async (id, file) => { + await onUploadCover(id, file) + setReloadKey((key) => key + 1) + } + : undefined + } + /> + {coverLoadStatus === 'loading' ? ( +

Loading cover...

+ ) : null} + {coverLoadStatus === 'error' ? ( +

Cover image could not be loaded.

+ ) : null} +
+ ) +} + +function mergeArtistLinks( + links: CatalogGraphLink[], + dictionaries: CatalogDictionaries | undefined, +) { + const artists = new Map< + string, + { + href: string + key: string + roles: string[] + roleSet: Set + title: string + } + >() + + for (const link of links) { + if (link.type !== 'artist') { + continue + } + + const key = link.id || link.title.toLowerCase() + const existing = artists.get(key) ?? { + href: catalogEntityHref({ kind: 'artist', id: link.id }), + key, + roles: [], + roleSet: new Set(), + title: link.title, + } + + for (const candidate of [link.relation, link.subtitle]) { + if (!isGraphArtistRole(candidate, dictionaries)) { + continue + } + + const role = formatRoleFacet(candidate ?? '', dictionaries) + if (!existing.roleSet.has(role)) { + if (existing.roles.length === 1 && existing.roles[0] === 'Artist') { + existing.roles = [] + } + existing.roleSet.add(role) + existing.roles.push(role) + } + } + + if (existing.roles.length === 0) { + existing.roles.push('Artist') + } + + artists.set(key, existing) + } + + return [...artists.values()] +} + +function groupGraphLinks( + links: CatalogGraphLink[], + title: string, + dictionaries: CatalogDictionaries | undefined, +) { const groups = new Map() for (const link of links) { const label = link.relation?.trim() || defaultGraphGroupLabel(link, title) - groups.set(label, [...(groups.get(label) ?? []), link]) + const displayLabel = formatGraphRelation(label, dictionaries) + groups.set(displayLabel, [...(groups.get(displayLabel) ?? []), link]) } return [...groups.entries()].map(([label, groupLinks]) => ({ @@ -179,6 +424,40 @@ function groupGraphLinks(links: CatalogGraphLink[], title: string) { })) } +function formatGraphLinkSubtitle( + link: CatalogGraphLink, + dictionaries: CatalogDictionaries | undefined, +) { + return isGraphArtistRole(link.subtitle, dictionaries) + ? formatRoleFacet(link.subtitle ?? '', dictionaries) + : link.subtitle +} + +function isRedundantGroupLabel( + sectionTitle: string, + label: string, + groupCount: number, +) { + if (groupCount !== 1) { + return false + } + + const normalizedTitle = normalizeLabel(sectionTitle) + const normalizedLabel = normalizeLabel(label) + + return ( + normalizedTitle === normalizedLabel || + (normalizedTitle === 'tracks' && normalizedLabel === 'tracklist') || + (normalizedTitle === 'labels' && normalizedLabel === 'label') || + (normalizedTitle === 'ownedcopies' && normalizedLabel === 'ownedcopy') || + (normalizedTitle === 'mediacoverage' && normalizedLabel === 'mediacoverage') + ) +} + +function normalizeLabel(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, '') +} + function defaultGraphGroupLabel(link: CatalogGraphLink, title: string) { switch (link.type) { case 'artist': diff --git a/src/features/catalog/CatalogWorkspace.tsx b/src/features/catalog/CatalogWorkspace.tsx index 0b342c8..c36c195 100644 --- a/src/features/catalog/CatalogWorkspace.tsx +++ b/src/features/catalog/CatalogWorkspace.tsx @@ -9,12 +9,16 @@ import type { RelationRecord } from '../relations/relationsData' import type { TrackRecord } from '../tracks/tracksData' import { LocalCatalogWorkspace } from './LocalCatalogWorkspace' import { ServerCatalogWorkspace } from './ServerCatalogWorkspace' +import type { CatalogDictionaries } from './catalogApi' type CatalogWorkspaceProps = { addEntryPanel?: ReactNode artists: ArtistRecord[] + dictionaries?: CatalogDictionaries labels?: LabelRecord[] locationSearch?: string + onRemoveReleaseCover?: (releaseId: string) => Promise | void + onUploadReleaseCover?: (releaseId: string, file: File) => Promise | void releases: ReleaseRecord[] tracks: TrackRecord[] ownedItems: OwnedItemRecord[] @@ -29,8 +33,11 @@ export function CatalogWorkspace(props: CatalogWorkspaceProps) { return ( ) diff --git a/src/features/catalog/FilterSelect.tsx b/src/features/catalog/FilterSelect.tsx index 80902f5..9519801 100644 --- a/src/features/catalog/FilterSelect.tsx +++ b/src/features/catalog/FilterSelect.tsx @@ -1,6 +1,12 @@ +export type FilterSelectOption = { + label: string + value: string +} + export type FilterSelectProps = { disabled?: boolean label: string + options?: FilterSelectOption[] value: string values: string[] onChange: (value: string) => void @@ -9,10 +15,14 @@ export type FilterSelectProps = { export function FilterSelect({ disabled = false, label, + options, value, values, onChange, }: FilterSelectProps) { + const selectOptions = + options ?? values.map((option) => ({ label: option, value: option })) + return (
diff --git a/src/features/catalog/ServerEntityWorkspace.tsx b/src/features/catalog/ServerEntityWorkspace.tsx index a98e950..d1cea34 100644 --- a/src/features/catalog/ServerEntityWorkspace.tsx +++ b/src/features/catalog/ServerEntityWorkspace.tsx @@ -5,6 +5,7 @@ import { loadCatalogGraphContext, loadRelationDetail, searchCatalog, + type CatalogDictionaries, type CatalogGraphContext, type CatalogSearchResult, type SearchEntityType, @@ -14,6 +15,7 @@ import { ServerCatalogTable } from './ServerCatalogControls' import { FilterSelect } from './FilterSelect' import type { CatalogLinkData } from './catalogLinks' import { uniqueValues } from './catalogGraph' +import { formatRoleFacet, roleFacetValue } from './catalogDisplayLabels' import { emptyServerFilters, resultKey, @@ -37,8 +39,11 @@ const emptyRelationCatalogData: CatalogLinkData = { type ServerEntityWorkspaceProps = { ariaLabel: string + dictionaries?: CatalogDictionaries entityType?: SearchEntityType locationSearch: string + onRemoveReleaseCover?: (releaseId: string) => Promise | void + onUploadReleaseCover?: (releaseId: string, file: File) => Promise | void placeholder: string queryParam: string routePath: AppRoutePath @@ -49,8 +54,11 @@ type ServerEntityWorkspaceProps = { export function ServerEntityWorkspace({ ariaLabel, + dictionaries, entityType, locationSearch, + onRemoveReleaseCover, + onUploadReleaseCover, placeholder, queryParam, routePath, @@ -308,6 +316,7 @@ export function ServerEntityWorkspace({ /> @@ -340,7 +352,10 @@ export function ServerEntityWorkspace({ ) : ( )} @@ -407,6 +422,7 @@ function RelationRouteDetailPanel({ function EntityFilterBar({ filters, + dictionaries, results, total, visibleCount, @@ -414,6 +430,7 @@ function EntityFilterBar({ onFilterChange, }: { filters: ServerCatalogFilters + dictionaries?: CatalogDictionaries results: CatalogSearchResult[] total: number visibleCount: number @@ -432,8 +449,9 @@ function EntityFilterBar({ const statusOptions = uniqueValues( results.flatMap((result) => result.facets.statuses), ) - const roleOptions = uniqueValues( + const roleOptions = uniqueRoleOptions( results.flatMap((result) => result.facets.roles), + dictionaries, ) const tagOptions = uniqueValues( results.flatMap((result) => result.facets.tags), @@ -470,7 +488,8 @@ function EntityFilterBar({ updateFilter('role', value)} /> () + + for (const role of uniqueValues(roles)) { + const value = roleFacetValue(role, dictionaries) + options.set(value, { + label: formatRoleFacet(role, dictionaries), + value, + }) + } + + return [...options.values()] +} + function EntitySearchField({ label, placeholder, diff --git a/src/features/catalog/api/releaseClient.ts b/src/features/catalog/api/releaseClient.ts index 8fb05c0..61aeea0 100644 --- a/src/features/catalog/api/releaseClient.ts +++ b/src/features/catalog/api/releaseClient.ts @@ -4,6 +4,7 @@ import { CatalogApiError, assertNoCollectionIds, getAllPages, + getJson, readJsonBody, sendDelete, sendJson, @@ -82,6 +83,10 @@ export async function createRelease( }) } +export async function loadRelease(releaseId: string) { + return getJson(`/api/releases/${encodeURIComponent(releaseId)}`) +} + export async function updateRelease( release: ReleaseRecord, tracks?: TrackRecord[], diff --git a/src/features/catalog/catalogDisplayLabels.ts b/src/features/catalog/catalogDisplayLabels.ts new file mode 100644 index 0000000..4c1fee7 --- /dev/null +++ b/src/features/catalog/catalogDisplayLabels.ts @@ -0,0 +1,133 @@ +import type { CatalogDictionaries } from './catalogApi' +import { activeDictionaries } from './api/catalogDefaults' +import { + creditRoleLabel, + relationTypeLabel, + toCreditRoleCode, +} from './api/catalogValueMappers' + +const matchedFieldLabels: Record = { + 'artist credits': 'Artist credits', + 'credit.contributor': 'Credit artist', + 'credit.role': 'Credit role', + credits: 'Credits', + genre: 'Genre', + label: 'Label', + 'label releases': 'Label releases', + medium: 'Media', + name: 'Name', + ownershipStatus: 'Ownership status', + 'release.type': 'Release type', + tag: 'Tag', + title: 'Title', +} + +const genericGraphRelations = new Set([ + 'artist', + 'artists', + 'artist links', + 'credit', + 'credits', +]) + +export function formatMatchedField(field: string) { + return matchedFieldLabels[field] ?? humanizeIdentifier(field) +} + +export function formatRoleFacet( + role: string, + dictionaries: CatalogDictionaries | undefined = activeDictionaries, +) { + if (isCreditRoleValue(role, dictionaries)) { + return creditRoleLabel(role, dictionaries) + } + + if (isArtistRelationValue(role, dictionaries)) { + return relationTypeLabel(role, 'artistRelationType', dictionaries) + } + + if (isTrackRelationValue(role, dictionaries)) { + return relationTypeLabel(role, 'trackRelationType', dictionaries) + } + + return humanizeIdentifier(role) +} + +export function roleFacetValue( + role: string, + dictionaries: CatalogDictionaries | undefined = activeDictionaries, +) { + return isCreditRoleValue(role, dictionaries) + ? toCreditRoleCode(role, dictionaries) + : role +} + +export function formatGraphRelation( + relation: string, + dictionaries: CatalogDictionaries | undefined = activeDictionaries, +) { + return formatRoleFacet(relation, dictionaries) +} + +export function isGraphArtistRole( + value: string | null | undefined, + dictionaries: CatalogDictionaries | undefined = activeDictionaries, +) { + const normalized = value?.trim().toLowerCase() + + return Boolean( + normalized && + !genericGraphRelations.has(normalized) && + (isCreditRoleValue(value ?? '', dictionaries) || + isArtistRelationValue(value ?? '', dictionaries) || + isTrackRelationValue(value ?? '', dictionaries)), + ) +} + +function isCreditRoleValue( + value: string, + dictionaries: CatalogDictionaries | undefined = activeDictionaries, +) { + const role = value.trim() + const code = toCreditRoleCode(role, dictionaries) + + return (dictionaries ?? activeDictionaries).creditRole.some( + (entry) => entry.code === code || entry.name === role, + ) +} + +function isArtistRelationValue( + value: string, + dictionaries: CatalogDictionaries | undefined = activeDictionaries, +) { + const relation = value.trim() + + return (dictionaries ?? activeDictionaries).artistRelationType.some( + (entry) => entry.code === relation || entry.name === relation, + ) +} + +function isTrackRelationValue( + value: string, + dictionaries: CatalogDictionaries | undefined = activeDictionaries, +) { + const relation = value.trim() + + return (dictionaries ?? activeDictionaries).trackRelationType.some( + (entry) => entry.code === relation || entry.name === relation, + ) +} + +function humanizeIdentifier(value: string) { + const words = value + .replaceAll('.', ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + + if (!words) { + return value + } + + return words.charAt(0).toUpperCase() + words.slice(1).toLowerCase() +} diff --git a/src/features/releases/ReleaseDetail.tsx b/src/features/releases/ReleaseDetail.tsx index b71c8ac..4d80460 100644 --- a/src/features/releases/ReleaseDetail.tsx +++ b/src/features/releases/ReleaseDetail.tsx @@ -293,12 +293,12 @@ export function ReleaseDetail({ } type ReleaseCoverPanelProps = { - release: ReleaseRecord + release: Pick onRemoveCover?: (releaseId: string) => Promise | void onUploadCover?: (releaseId: string, file: File) => Promise | void } -function ReleaseCoverPanel({ +export function ReleaseCoverPanel({ release, onRemoveCover, onUploadCover, From 72da795e96e1f5f4a4d6d1160c7a86d1a94f0c8e Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Fri, 29 May 2026 12:40:28 +0300 Subject: [PATCH 2/3] Split oversized web files --- src/App.catalog-actions.test.tsx | 161 ------------------ src/App.catalog-server-detail.test.tsx | 57 +++++++ src/features/catalog/ServerEntityFilters.tsx | 106 ++++++++++++ .../catalog/ServerEntityWorkspace.tsx | 104 +---------- src/test/catalogActionFixtures.ts | 116 +++++++++++++ 5 files changed, 280 insertions(+), 264 deletions(-) create mode 100644 src/App.catalog-server-detail.test.tsx create mode 100644 src/features/catalog/ServerEntityFilters.tsx create mode 100644 src/test/catalogActionFixtures.ts diff --git a/src/App.catalog-actions.test.tsx b/src/App.catalog-actions.test.tsx index bf69a17..4414e53 100644 --- a/src/App.catalog-actions.test.tsx +++ b/src/App.catalog-actions.test.tsx @@ -171,52 +171,6 @@ describe('App catalog actions', () => { ).toBeInTheDocument() }) - it('renders server release details with readable roles, merged artists, and cover actions', async () => { - window.history.pushState({}, '', '/catalog') - h.clearCatalogForTests() - h.mockFetch( - searchResponseWithRelease(), - graphResponseForReleaseWithDuplicateArtists(), - releaseDetailWithoutCover(), - ) - - h.render() - - const detailPanel = await h.screen.findByRole('complementary', { - name: 'Stripped', - }) - const artistsSection = h.detailSection(detailPanel, 'Artists') - - expect( - h.within(artistsSection).getByRole('link', { name: 'Depeche Mode' }), - ).toHaveAttribute('href', '/artists?artist=artist-depeche-mode') - expect( - h.within(artistsSection).getByText('Main artist'), - ).toBeInTheDocument() - expect( - h.within(artistsSection).getAllByRole('link', { - name: 'Depeche Mode', - }), - ).toHaveLength(1) - expect( - h.within(detailPanel).queryByRole('heading', { name: 'Credits' }), - ).not.toBeInTheDocument() - expect( - h.within(detailPanel).queryByRole('heading', { name: 'TRACKLIST' }), - ).not.toBeInTheDocument() - expect( - h.within(detailPanel).queryByRole('heading', { name: 'LABEL' }), - ).not.toBeInTheDocument() - expect( - h.within(detailPanel).getByLabelText('Upload cover'), - ).toBeInTheDocument() - expect( - h.within(detailPanel).queryByRole('button', { - name: 'Load editable view', - }), - ).not.toBeInTheDocument() - }) - it('keeps label owned coverage tied to release ids instead of shared titles', async () => { window.history.pushState({}, '', '/labels') h.seedCatalogForTests({ @@ -643,118 +597,3 @@ describe('App catalog actions', () => { }, ) }) - -function searchResponseWithRelease() { - return h.jsonResponse({ - items: [ - { - id: 'release-stripped', - type: 'release', - title: 'Stripped', - subtitle: 'Mute', - summary: 'Imported single release.', - matchedFields: ['title', 'credit.role'], - snippets: ['Depeche Mode · Stripped'], - facets: { - roles: ['mainArtist'], - media: [], - statuses: [], - tags: [], - labelId: 'label-mute', - collectorSignals: [], - }, - rank: 1, - }, - ], - limit: 100, - offset: 0, - total: 1, - }) -} - -function graphResponseForReleaseWithDuplicateArtists() { - return h.jsonResponse({ - entity: { - id: 'release-stripped', - type: 'release', - title: 'Stripped', - subtitle: 'Mute', - summary: 'Imported single release.', - }, - sections: { - artists: [ - { - id: 'artist-depeche-mode', - type: 'artist', - title: 'Depeche Mode', - subtitle: 'Group', - relation: 'mainArtist', - }, - ], - credits: [ - { - id: 'artist-depeche-mode', - type: 'artist', - title: 'Depeche Mode', - subtitle: 'mainArtist', - relation: 'credit', - }, - ], - releases: [], - tracks: [ - { - id: 'track-stripped', - type: 'track', - title: 'Stripped', - subtitle: '1', - relation: 'tracklist', - }, - ], - ownedCopies: [], - labels: [ - { - id: 'label-mute', - type: 'label', - title: 'Mute', - subtitle: 'BONG 010', - relation: 'label', - }, - ], - playlists: [], - relations: [], - media: [], - }, - collectorSignals: [], - }) -} - -function releaseDetailWithoutCover() { - return h.jsonResponse({ - id: 'release-stripped', - title: 'Stripped', - type: 'standalone', - year: 1986, - releaseDate: '1986-02-10', - genres: [], - tags: [], - coverImage: null, - isVariousArtists: false, - notOnLabel: false, - artistCredits: [ - { - artistId: 'artist-depeche-mode', - artistName: 'Depeche Mode', - role: 'mainArtist', - }, - ], - labels: [ - { - labelId: 'label-mute', - name: 'Mute', - catalogNumber: 'BONG 010', - hasNoCatalogNumber: false, - }, - ], - tracklist: [], - }) -} diff --git a/src/App.catalog-server-detail.test.tsx b/src/App.catalog-server-detail.test.tsx new file mode 100644 index 0000000..691a9a8 --- /dev/null +++ b/src/App.catalog-server-detail.test.tsx @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import * as h from './test/appTestHarness' +import { + graphResponseForReleaseWithDuplicateArtists, + releaseDetailWithoutCover, + searchResponseWithRelease, +} from './test/catalogActionFixtures' + +h.setupAppTestHooks() + +describe('App catalog server detail panel', () => { + it('renders release details with readable roles, merged artists, and cover actions', async () => { + window.history.pushState({}, '', '/catalog') + h.clearCatalogForTests() + h.mockFetch( + searchResponseWithRelease(), + graphResponseForReleaseWithDuplicateArtists(), + releaseDetailWithoutCover(), + ) + + h.render() + + const detailPanel = await h.screen.findByRole('complementary', { + name: 'Stripped', + }) + const artistsSection = h.detailSection(detailPanel, 'Artists') + + expect( + h.within(artistsSection).getByRole('link', { name: 'Depeche Mode' }), + ).toHaveAttribute('href', '/artists?artist=artist-depeche-mode') + expect( + h.within(artistsSection).getByText('Main artist'), + ).toBeInTheDocument() + expect( + h.within(artistsSection).getAllByRole('link', { + name: 'Depeche Mode', + }), + ).toHaveLength(1) + expect( + h.within(detailPanel).queryByRole('heading', { name: 'Credits' }), + ).not.toBeInTheDocument() + expect( + h.within(detailPanel).queryByRole('heading', { name: 'TRACKLIST' }), + ).not.toBeInTheDocument() + expect( + h.within(detailPanel).queryByRole('heading', { name: 'LABEL' }), + ).not.toBeInTheDocument() + expect( + h.within(detailPanel).getByLabelText('Upload cover'), + ).toBeInTheDocument() + expect( + h.within(detailPanel).queryByRole('button', { + name: 'Load editable view', + }), + ).not.toBeInTheDocument() + }) +}) diff --git a/src/features/catalog/ServerEntityFilters.tsx b/src/features/catalog/ServerEntityFilters.tsx new file mode 100644 index 0000000..bba802c --- /dev/null +++ b/src/features/catalog/ServerEntityFilters.tsx @@ -0,0 +1,106 @@ +import { FilterSelect } from './FilterSelect' +import { formatRoleFacet, roleFacetValue } from './catalogDisplayLabels' +import { uniqueValues } from './catalogGraph' +import type { CatalogDictionaries, CatalogSearchResult } from './catalogApi' +import type { ServerCatalogFilters } from './catalogWorkspaceShared' + +export function EntityFilterBar({ + filters, + dictionaries, + results, + total, + visibleCount, + onClearFilters, + onFilterChange, +}: { + filters: ServerCatalogFilters + dictionaries?: CatalogDictionaries + results: CatalogSearchResult[] + total: number + visibleCount: number + onClearFilters: () => void + onFilterChange: (filters: ServerCatalogFilters) => void +}) { + function updateFilter( + key: Key, + value: ServerCatalogFilters[Key], + ) { + onFilterChange({ ...filters, [key]: value }) + } + + const mediaOptions = uniqueValues( + results.flatMap((result) => result.facets.media), + ) + const statusOptions = uniqueValues( + results.flatMap((result) => result.facets.statuses), + ) + const roleOptions = uniqueRoleOptions( + results.flatMap((result) => result.facets.roles), + dictionaries, + ) + const tagOptions = uniqueValues( + results.flatMap((result) => result.facets.tags), + ) + + return ( +
+
+ + + {visibleCount} shown · {total} total + +
+ +
+ updateFilter('media', value)} + /> + updateFilter('status', value)} + /> + updateFilter('role', value)} + /> + updateFilter('tag', value)} + /> +
+
+ ) +} + +function uniqueRoleOptions( + roles: string[], + dictionaries: CatalogDictionaries | undefined, +) { + const options = new Map() + + for (const role of uniqueValues(roles)) { + const value = roleFacetValue(role, dictionaries) + options.set(value, { + label: formatRoleFacet(role, dictionaries), + value, + }) + } + + return [...options.values()] +} diff --git a/src/features/catalog/ServerEntityWorkspace.tsx b/src/features/catalog/ServerEntityWorkspace.tsx index d1cea34..2b45fca 100644 --- a/src/features/catalog/ServerEntityWorkspace.tsx +++ b/src/features/catalog/ServerEntityWorkspace.tsx @@ -12,10 +12,8 @@ import { } from './catalogApi' import { GraphDetailPanel } from './CatalogGraphDetailPanel' import { ServerCatalogTable } from './ServerCatalogControls' -import { FilterSelect } from './FilterSelect' +import { EntityFilterBar } from './ServerEntityFilters' import type { CatalogLinkData } from './catalogLinks' -import { uniqueValues } from './catalogGraph' -import { formatRoleFacet, roleFacetValue } from './catalogDisplayLabels' import { emptyServerFilters, resultKey, @@ -420,106 +418,6 @@ function RelationRouteDetailPanel({ ) } -function EntityFilterBar({ - filters, - dictionaries, - results, - total, - visibleCount, - onClearFilters, - onFilterChange, -}: { - filters: ServerCatalogFilters - dictionaries?: CatalogDictionaries - results: CatalogSearchResult[] - total: number - visibleCount: number - onClearFilters: () => void - onFilterChange: (filters: ServerCatalogFilters) => void -}) { - function updateFilter( - key: Key, - value: ServerCatalogFilters[Key], - ) { - onFilterChange({ ...filters, [key]: value }) - } - const mediaOptions = uniqueValues( - results.flatMap((result) => result.facets.media), - ) - const statusOptions = uniqueValues( - results.flatMap((result) => result.facets.statuses), - ) - const roleOptions = uniqueRoleOptions( - results.flatMap((result) => result.facets.roles), - dictionaries, - ) - const tagOptions = uniqueValues( - results.flatMap((result) => result.facets.tags), - ) - - return ( -
-
- - - {visibleCount} shown · {total} total - -
- -
- updateFilter('media', value)} - /> - updateFilter('status', value)} - /> - updateFilter('role', value)} - /> - updateFilter('tag', value)} - /> -
-
- ) -} - -function uniqueRoleOptions( - roles: string[], - dictionaries: CatalogDictionaries | undefined, -) { - const options = new Map() - - for (const role of uniqueValues(roles)) { - const value = roleFacetValue(role, dictionaries) - options.set(value, { - label: formatRoleFacet(role, dictionaries), - value, - }) - } - - return [...options.values()] -} - function EntitySearchField({ label, placeholder, diff --git a/src/test/catalogActionFixtures.ts b/src/test/catalogActionFixtures.ts new file mode 100644 index 0000000..380ed01 --- /dev/null +++ b/src/test/catalogActionFixtures.ts @@ -0,0 +1,116 @@ +import { jsonResponse } from './appTestHarness' + +export function searchResponseWithRelease() { + return jsonResponse({ + items: [ + { + id: 'release-stripped', + type: 'release', + title: 'Stripped', + subtitle: 'Mute', + summary: 'Imported single release.', + matchedFields: ['title', 'credit.role'], + snippets: ['Depeche Mode · Stripped'], + facets: { + roles: ['mainArtist'], + media: [], + statuses: [], + tags: [], + labelId: 'label-mute', + collectorSignals: [], + }, + rank: 1, + }, + ], + limit: 100, + offset: 0, + total: 1, + }) +} + +export function graphResponseForReleaseWithDuplicateArtists() { + return jsonResponse({ + entity: { + id: 'release-stripped', + type: 'release', + title: 'Stripped', + subtitle: 'Mute', + summary: 'Imported single release.', + }, + sections: { + artists: [ + { + id: 'artist-depeche-mode', + type: 'artist', + title: 'Depeche Mode', + subtitle: 'Group', + relation: 'mainArtist', + }, + ], + credits: [ + { + id: 'artist-depeche-mode', + type: 'artist', + title: 'Depeche Mode', + subtitle: 'mainArtist', + relation: 'credit', + }, + ], + releases: [], + tracks: [ + { + id: 'track-stripped', + type: 'track', + title: 'Stripped', + subtitle: '1', + relation: 'tracklist', + }, + ], + ownedCopies: [], + labels: [ + { + id: 'label-mute', + type: 'label', + title: 'Mute', + subtitle: 'BONG 010', + relation: 'label', + }, + ], + playlists: [], + relations: [], + media: [], + }, + collectorSignals: [], + }) +} + +export function releaseDetailWithoutCover() { + return jsonResponse({ + id: 'release-stripped', + title: 'Stripped', + type: 'standalone', + year: 1986, + releaseDate: '1986-02-10', + genres: [], + tags: [], + coverImage: null, + isVariousArtists: false, + notOnLabel: false, + artistCredits: [ + { + artistId: 'artist-depeche-mode', + artistName: 'Depeche Mode', + role: 'mainArtist', + }, + ], + labels: [ + { + labelId: 'label-mute', + name: 'Mute', + catalogNumber: 'BONG 010', + hasNoCatalogNumber: false, + }, + ], + tracklist: [], + }) +} From 39925c3183842764aad982d6284ccfe9fa816e4c Mon Sep 17 00:00:00 2001 From: "r.osipin" Date: Fri, 29 May 2026 12:49:37 +0300 Subject: [PATCH 3/3] Address catalog review feedback --- src/App.server-navigation.test.tsx | 8 +++--- .../catalog/CatalogGraphDetailPanel.tsx | 27 ++++++++++--------- .../catalog/ServerCatalogControls.tsx | 6 +++-- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/App.server-navigation.test.tsx b/src/App.server-navigation.test.tsx index 63e2891..d7da8e7 100644 --- a/src/App.server-navigation.test.tsx +++ b/src/App.server-navigation.test.tsx @@ -69,16 +69,14 @@ describe('App workspace navigation', () => { expect( await h.screen.findByRole('heading', { name: 'Release records' }), ).toBeInTheDocument() - await h.waitFor(() => { - expect(fetchMock.mock.calls).toHaveLength(13) - }) + const callCountAfterReleases = fetchMock.mock.calls.length await user.click(h.screen.getByRole('link', { name: 'Tracks' })) expect( - h.screen.getByRole('heading', { name: 'Track records' }), + await h.screen.findByRole('heading', { name: 'Track records' }), ).toBeInTheDocument() - expect(fetchMock.mock.calls).toHaveLength(13) + expect(fetchMock.mock.calls).toHaveLength(callCountAfterReleases) }) it('shows mutation errors in editable workspaces', async () => { diff --git a/src/features/catalog/CatalogGraphDetailPanel.tsx b/src/features/catalog/CatalogGraphDetailPanel.tsx index 95798fc..9259bc6 100644 --- a/src/features/catalog/CatalogGraphDetailPanel.tsx +++ b/src/features/catalog/CatalogGraphDetailPanel.tsx @@ -286,6 +286,7 @@ function ServerReleaseCoverPanel({ let isCurrent = true queueMicrotask(() => { if (isCurrent) { + setCoverImage(undefined) setCoverLoadStatus('loading') } }) @@ -320,7 +321,11 @@ function ServerReleaseCoverPanel({ ) return ( -
+

Cover

- {coverLoadStatus === 'loading' ? ( -

Loading cover...

- ) : null} - {coverLoadStatus === 'error' ? ( -

Cover image could not be loaded.

- ) : null}
) } @@ -386,10 +385,14 @@ function mergeArtistLinks( } const role = formatRoleFacet(candidate ?? '', dictionaries) + if (role !== 'Artist' && existing.roleSet.has('Artist')) { + existing.roleSet.delete('Artist') + existing.roles = existing.roles.filter( + (existingRole) => existingRole !== 'Artist', + ) + } + if (!existing.roleSet.has(role)) { - if (existing.roles.length === 1 && existing.roles[0] === 'Artist') { - existing.roles = [] - } existing.roleSet.add(role) existing.roles.push(role) } @@ -397,6 +400,7 @@ function mergeArtistLinks( if (existing.roles.length === 0) { existing.roles.push('Artist') + existing.roleSet.add('Artist') } artists.set(key, existing) @@ -449,8 +453,7 @@ function isRedundantGroupLabel( normalizedTitle === normalizedLabel || (normalizedTitle === 'tracks' && normalizedLabel === 'tracklist') || (normalizedTitle === 'labels' && normalizedLabel === 'label') || - (normalizedTitle === 'ownedcopies' && normalizedLabel === 'ownedcopy') || - (normalizedTitle === 'mediacoverage' && normalizedLabel === 'mediacoverage') + (normalizedTitle === 'ownedcopies' && normalizedLabel === 'ownedcopy') ) } diff --git a/src/features/catalog/ServerCatalogControls.tsx b/src/features/catalog/ServerCatalogControls.tsx index 86ef583..4fad8fa 100644 --- a/src/features/catalog/ServerCatalogControls.tsx +++ b/src/features/catalog/ServerCatalogControls.tsx @@ -368,8 +368,10 @@ export function ServerCatalogTable({ ) : null} - formatRoleFacet(role, dictionaries), + values={uniqueValues( + result.facets.roles.map((role) => + formatRoleFacet(role, dictionaries), + ), )} variant="credit" />