From b24721d5602a5853636f2960e97bcbdaf832ae04 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:13:55 -0400 Subject: [PATCH 1/6] Start of series work on bib --- src/models/BibDetails.ts | 55 ++++++++++++++++++++++++++++++++++-- src/types/bibDetailsTypes.ts | 2 +- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/models/BibDetails.ts b/src/models/BibDetails.ts index 476ba9af0..0d22e0c65 100644 --- a/src/models/BibDetails.ts +++ b/src/models/BibDetails.ts @@ -7,7 +7,7 @@ import type { AnyBibDetail, MarcLinkedDetail, AnyMarcDetail, - ContributorEntry, + DisplayPackedEntry, } from "../types/bibDetailsTypes" import { convertToSentenceCase, @@ -78,7 +78,7 @@ export default class BibDetails { ? "creatorsDisplay" : "contributorsDisplay" - const mapDisplay = (arr?: ContributorEntry[]): BibDetailURL[] => + const mapDisplay = (arr?: DisplayPackedEntry[]): BibDetailURL[] => Array.isArray(arr) ? arr.map(({ display, "@value": name }) => { const [, roles] = display.split(name) @@ -115,6 +115,47 @@ export default class BibDetails { : null } + buildLinkedSeriesDetail(field): LinkedBibDetail | null { + const displayField = + field === "series" ? "seriesDisplay" : "seriesAddedEntryDisplay" + + const mapDisplay = (arr?: DisplayPackedEntry[]): BibDetailURL[] => + Array.isArray(arr) + ? arr.map(({ display, "@value": name }) => { + const [, unlinkedText] = display.split(name) + return { + url: + urlLabel: name, + text: unlinkedText?.trim() || undefined, + } + }) + : [] + + const displayValues = mapDisplay(this.bib[displayField]) + + // Set of displayed names for deduping + const seen = new Set(displayValues.map((v) => v.urlLabel)) + + // Literals (fallback) + const literalValues: string[] = this.bib[literalField] || [] + const literals: BibDetailURL[] = literalValues + .filter((name) => !seen.has(name)) + .map((name) => ({ + url: getContributorSearchURL(name), + urlLabel: name, + })) + + const combinedValues = [...displayValues, ...literals] + + return combinedValues?.length > 0 + ? { + label, + link: "internal", + value: combinedValues, + } + : null + } + buildAnnotatedMarcDetails( annotatedMarc: AnnotatedMarcField[] ): AnyMarcDetail[] { @@ -212,6 +253,7 @@ export default class BibDetails { "addedAuthorTitle", "placeOfPublication", "series", + "seriesAddedEntry", "uniformTitle", "subjectLiteral", "titleAlt", @@ -228,7 +270,7 @@ export default class BibDetails { { field: "summary", label: "Summary" }, { field: "donor", label: "Donor/sponsor" }, { field: "series", label: "Series" }, - { field: "seriesStatement", label: "Series statement" }, + { field: "seriesAddedEntry", label: "Series added entry" }, { field: "uniformTitle", label: "Uniform title" }, { field: "titleAlt", label: "Alternative title" }, { field: "formerTitle", label: "Former title" }, @@ -358,6 +400,7 @@ export default class BibDetails { label: string field: string }): LinkedBibDetail { + console.log(fieldMapping) const value = this.bib[fieldMapping.field] if (!value?.length) return null if (fieldMapping.field === "contributorLiteral") { @@ -366,6 +409,12 @@ export default class BibDetails { "Additional authors" ) } + if ( + fieldMapping.field === "seriesAddedEntry" || + fieldMapping.field === "series" + ) { + return this.buildLinkedSeriesDetail(fieldMapping.field) + } return { link: "internal", label: convertToSentenceCase(fieldMapping.label), diff --git a/src/types/bibDetailsTypes.ts b/src/types/bibDetailsTypes.ts index ac48a4b15..0f6d09d21 100644 --- a/src/types/bibDetailsTypes.ts +++ b/src/types/bibDetailsTypes.ts @@ -48,7 +48,7 @@ export interface FieldMapping { field: string } -export interface ContributorEntry { +export interface DisplayPackedEntry { display: string "@value": string } From 6fd953a20bf0f35f137190d8de4d7d5b98373786 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:51:21 -0400 Subject: [PATCH 2/6] Gnarly first go at series --- src/models/BibDetails.ts | 102 +++++++++++++++++++++++++++------------ 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/src/models/BibDetails.ts b/src/models/BibDetails.ts index 0d22e0c65..1f51d24cd 100644 --- a/src/models/BibDetails.ts +++ b/src/models/BibDetails.ts @@ -116,44 +116,52 @@ export default class BibDetails { } buildLinkedSeriesDetail(field): LinkedBibDetail | null { - const displayField = - field === "series" ? "seriesDisplay" : "seriesAddedEntryDisplay" - - const mapDisplay = (arr?: DisplayPackedEntry[]): BibDetailURL[] => - Array.isArray(arr) - ? arr.map(({ display, "@value": name }) => { - const [, unlinkedText] = display.split(name) - return { - url: - urlLabel: name, - text: unlinkedText?.trim() || undefined, - } - }) - : [] + let displayField = "seriesDisplay" + let label = "Series" + switch (field) { + case "seriesAddedEntry": + displayField = "seriesAddedEntryDisplay" + label = "Series added entry" + break + case "seriesUniformTitle": + displayField = "seriesUniformTitleDisplay" + label = "Series uniform title" + break + } - const displayValues = mapDisplay(this.bib[displayField]) + const getSeriesSearchUrl = (name: string) => + `/search?filters[series][0]=${encodeURIComponentWithPeriods(name)}` + + const displayData: DisplayPackedEntry[] = this.bib[displayField] || [] + const displayValues: BibDetailURL[] = displayData.map( + ({ display, "@value": name }) => { + const [, unlinkedText] = display.split(name) + return { + url: getSeriesSearchUrl(name), + urlLabel: name, + text: unlinkedText?.trim() || undefined, + } + } + ) - // Set of displayed names for deduping - const seen = new Set(displayValues.map((v) => v.urlLabel)) + // Set of field values for deduping + const seen = new Set( + displayData.map((v) => + field === "seriesAddedEntry" ? v.display : v["@value"] + ) + ) // Literals (fallback) - const literalValues: string[] = this.bib[literalField] || [] - const literals: BibDetailURL[] = literalValues + const literals: BibDetailURL[] = (this.bib[field] || []) .filter((name) => !seen.has(name)) .map((name) => ({ - url: getContributorSearchURL(name), + url: getSeriesSearchUrl(name), urlLabel: name, })) - const combinedValues = [...displayValues, ...literals] + const value = [...displayValues, ...literals] - return combinedValues?.length > 0 - ? { - label, - link: "internal", - value: combinedValues, - } - : null + return value.length ? { label, link: "internal", value } : null } buildAnnotatedMarcDetails( @@ -254,6 +262,7 @@ export default class BibDetails { "placeOfPublication", "series", "seriesAddedEntry", + "seriesUniformTitle", "uniformTitle", "subjectLiteral", "titleAlt", @@ -271,6 +280,7 @@ export default class BibDetails { { field: "donor", label: "Donor/sponsor" }, { field: "series", label: "Series" }, { field: "seriesAddedEntry", label: "Series added entry" }, + { field: "seriesUniformTitle", label: "Series uniform title" }, { field: "uniformTitle", label: "Uniform title" }, { field: "titleAlt", label: "Alternative title" }, { field: "formerTitle", label: "Former title" }, @@ -301,10 +311,37 @@ export default class BibDetails { ) } + // Series uniform title values should display under Series added entry, + // combine these details + combineSeriesAddedEntries(resourceEndpointDetails: AnyBibDetail[]) { + const addedEntry = resourceEndpointDetails.find( + (d) => d.label === "Series added entry" + ) as LinkedBibDetail + const uniformTitle = resourceEndpointDetails.find( + (d) => d.label === "Series uniform title" + ) as LinkedBibDetail + + if (uniformTitle) { + if (addedEntry) { + addedEntry.value = [...addedEntry.value, ...uniformTitle.value] + return resourceEndpointDetails.filter( + (d) => d.label !== "Series uniform title" + ) + } + uniformTitle.label = "Series added entry" + } + + return resourceEndpointDetails + } + combineBibDetailsData( resourceEndpointDetails: AnyBibDetail[], annotatedMarcDetails: AnyMarcDetail[] ): AnyBibDetail[] { + resourceEndpointDetails = this.combineSeriesAddedEntries( + resourceEndpointDetails + ) + const normalizeValues = (val: any) => { if (!val) return [] if (Array.isArray(val)) { @@ -401,8 +438,6 @@ export default class BibDetails { field: string }): LinkedBibDetail { console.log(fieldMapping) - const value = this.bib[fieldMapping.field] - if (!value?.length) return null if (fieldMapping.field === "contributorLiteral") { return this.buildLinkedContributorDetail( "contributorLiteral", @@ -411,10 +446,15 @@ export default class BibDetails { } if ( fieldMapping.field === "seriesAddedEntry" || - fieldMapping.field === "series" + fieldMapping.field === "series" || + fieldMapping.field === "seriesUniformTitle" ) { return this.buildLinkedSeriesDetail(fieldMapping.field) } + + const value = this.bib[fieldMapping.field] + if (!value?.length) return null + return { link: "internal", label: convertToSentenceCase(fieldMapping.label), From f7112c17c40782da514db05569dce080f6ffc785 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:53:06 -0400 Subject: [PATCH 3/6] Final tweaks and fixtures/tests --- __test__/fixtures/bibFixtures.ts | 673 +++++++++++++++++++++- src/components/BibPage/BibDetail.test.tsx | 5 +- src/models/BibDetails.ts | 5 +- src/models/modelTests/BibDetails.test.ts | 23 + 4 files changed, 699 insertions(+), 7 deletions(-) diff --git a/__test__/fixtures/bibFixtures.ts b/__test__/fixtures/bibFixtures.ts index 7ee8dc1fb..b688f6451 100644 --- a/__test__/fixtures/bibFixtures.ts +++ b/__test__/fixtures/bibFixtures.ts @@ -1231,6 +1231,677 @@ export const bibWithItems = { }, } +export const bibWithSeries = { + resource: { + "@context": + "http://discovery-api-qa.nypl.org/api/v0.1/discovery/context_all.jsonld", + "@type": ["nypl:Item", "nypl:Resource"], + "@id": "res:b16470373", + buildingLocationIds: ["rc"], + carrierType: [ + { + "@id": "carriertypes:nc", + prefLabel: "volume", + }, + ], + contributorLiteral: ["Spada, Pietro, 1935-"], + contributorsDisplay: [ + { + display: "Spada, Pietro, 1935-, editor", + "@value": "Spada, Pietro, 1935-", + }, + ], + createdString: ["2006"], + createdYear: 2006, + creatorLiteral: ["Rossini, Gioacchino, 1792-1868"], + creatorsDisplay: [ + { + display: "Rossini, Gioacchino, 1792-1868", + "@value": "Rossini, Gioacchino, 1792-1868", + }, + ], + dateStartYear: 2006, + dateString: ["2006"], + dates: [ + { + range: { + lt: "2007", + gte: "2006", + }, + raw: "061108s2006 it uua i n lat cccm4a ", + tag: "s", + }, + ], + dimensions: ["31 cm."], + extent: ["1 score ([ii], 18 p.) ;"], + idOclc: ["75399987"], + identifier: [ + { + "@type": "bf:ShelfMark", + "@value": "JMG 06-1852", + }, + { + "@type": "nypl:Bnumber", + "@value": "16470373", + }, + { + "@type": "nypl:Oclc", + "@value": "75399987", + }, + { + "@type": "bf:Identifier", + "@value": "BS. 1607 Boccaccini & Spada", + }, + { + "@type": "bf:Identifier", + "@value": "(OCoLC)72521271", + }, + { + "@type": "bf:Identifier", + "@value": "(OCoLC)75399987", + }, + { + "@type": "bf:Identifier", + "@value": "(WaOLN)M060000006", + }, + ], + issuance: [ + { + "@id": "urn:biblevel:m", + prefLabel: "monograph/item", + }, + ], + itemAggregations: [ + { + "@type": "nypl:Aggregation", + "@id": "res:location", + id: "location", + field: "location", + values: [ + { + value: "loc:rcpm2", + count: 1, + label: "Offsite", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:format", + id: "format", + field: "format", + values: [ + { + value: "Notated music", + count: 1, + label: "Notated music", + }, + ], + }, + { + "@type": "nypl:Aggregation", + "@id": "res:status", + id: "status", + field: "status", + values: [ + { + value: "status:a", + count: 1, + label: "Available", + }, + ], + }, + ], + items: [ + { + "@id": "res:i17270445", + "@type": ["bf:Item"], + accessMessage: [ + { + "@id": "accessMessage:2", + prefLabel: "Request in advance", + }, + ], + catalogItemType: [ + { + "@id": "catalogItemType:57", + prefLabel: "printed music limited circ MaRLI", + }, + ], + eddFulfillment: { + "@id": "fulfillment:recap-edd", + }, + eddRequestable: true, + formatLiteral: ["Notated music"], + holdingLocation: [ + { + "@id": "loc:rcpm2", + prefLabel: "Offsite", + }, + ], + idBarcode: ["33433073832424"], + identifier: [ + { + "@type": "bf:ShelfMark", + "@value": "JMG 06-1852", + }, + { + "@type": "bf:Barcode", + "@value": "33433073832424", + }, + ], + owner: [ + { + "@id": "orgs:1002", + prefLabel: + "New York Public Library for the Performing Arts, Dorothy and Lewis B. Cullman Center", + }, + ], + physFulfillment: { + "@id": "fulfillment:recap-offsite", + }, + physRequestable: true, + physicalLocation: ["JMG 06-1852"], + recapCustomerCode: ["NP"], + requestable: [true], + shelfMark: ["JMG 06-1852"], + specRequestable: false, + status: [ + { + "@id": "status:a", + prefLabel: "Available", + }, + ], + uri: "i17270445", + idNyplSourceId: { + "@type": "SierraNypl", + "@value": "17270445", + }, + }, + ], + language: [ + { + "@id": "lang:lat", + prefLabel: "Latin", + }, + ], + lccClassification: ["M2018.R83 C57"], + materialType: [ + { + "@id": "resourcetypes:not", + prefLabel: "Notated music", + }, + ], + mediaType: [ + { + "@id": "mediatypes:n", + prefLabel: "unmediated", + }, + ], + note: [ + { + noteType: "Note", + "@type": "bf:Note", + prefLabel: "For vocal soloists (TTB) with orchestra.", + }, + { + noteType: "Note", + "@type": "bf:Note", + prefLabel: + "Preface in Italian with English, French, and German translations.", + }, + { + noteType: "Language", + "@type": "bf:Note", + prefLabel: "Latin words.", + }, + ], + numCheckinCardItems: 0, + numElectronicResources: 0, + numItemDatesParsed: 0, + numItemVolumesParsed: 0, + numItemsMatched: 1, + numItemsTotal: 1, + nyplSource: ["sierra-nypl"], + parallelContributorLiteral: [null], + parallelContributorsDisplay: [], + parallelCreatorLiteral: [null], + parallelCreatorsDisplay: [], + parallelSeriesAddedEntry: [], + parallelSeriesAddedEntryDisplay: [], + parallelSubjectLiteral: [], + physicalDescription: ["1 score ([ii], 18 p.) ; 31 cm."], + placeOfPublication: ["Pavona di Albano Laziale, Roma"], + popularity: 1, + publicationStatement: [ + "Pavona di Albano Laziale, Roma : Boccaccini & Spada, c2006.", + ], + publisherLiteral: ["Boccaccini & Spada"], + series: ["Inediti e rarità rossiniane ;"], + seriesDisplay: [ + { + display: "Inediti e rarità rossiniane ; 12", + "@value": "Inediti e rarità rossiniane ;", + }, + ], + seriesUniformTitle: [ + "Rossini, Gioacchino 1792-1868. Works. Selections (Boccaccini & Spada editore) ;", + ], + seriesUniformTitleDisplay: [ + { + display: + "Rossini, Gioacchino 1792-1868. Works. Selections (Boccaccini & Spada editore) ; 12.", + "@value": + "Rossini, Gioacchino 1792-1868. Works. Selections (Boccaccini & Spada editore) ;", + }, + ], + shelfMark: ["JMG 06-1852"], + subjectLiteral: ["Sacred vocal trios with orchestra -- Scores."], + title: ["Christe : per 2 tenori, basso ed orchestra"], + titleDisplay: [ + "Christe : per 2 tenori, basso ed orchestra / Gioacchino Rossini ; a cura di Pietro Spada.", + ], + type: ["nypl:Item"], + updatedAt: 1776009782717, + uri: "b16470373", + updatedAtDate: "2026-04-12T16:03:02.717Z", + hasItemVolumes: false, + hasItemDates: false, + collection: [ + { + "@id": "pam", + prefLabel: "Music Division", + buildingLocationLabel: + "The New York Public Library for the Performing Arts (LPA)", + locationsPath: "locations/lpa/music-division", + }, + ], + format: [ + { + "@id": "c", + prefLabel: "Notated music", + }, + ], + electronicResources: [], + }, + annotatedMarc: { + id: "16470373", + nyplSource: "sierra-nypl", + fields: [ + { + label: "Author", + values: [ + { + content: "Rossini, Gioacchino, 1792-1868.", + source: { + ind1: "1", + ind2: " ", + content: null, + marcTag: "100", + fieldTag: "a", + subfields: [ + { + tag: "a", + content: "Rossini, Gioacchino,", + }, + { + tag: "d", + content: "1792-1868.", + }, + ], + }, + }, + ], + }, + { + label: "Title", + values: [ + { + content: + "Christe : per 2 tenori, basso ed orchestra / Gioacchino Rossini ; a cura di Pietro Spada.", + source: { + ind1: "1", + ind2: "0", + content: null, + marcTag: "245", + fieldTag: "t", + subfields: [ + { + tag: "a", + content: "Christe :", + }, + { + tag: "b", + content: "per 2 tenori, basso ed orchestra /", + }, + { + tag: "c", + content: "Gioacchino Rossini ; a cura di Pietro Spada.", + }, + ], + }, + }, + ], + }, + { + label: "Imprint", + values: [ + { + content: + "Pavona di Albano Laziale, Roma : Boccaccini & Spada, c2006.", + source: { + ind1: " ", + ind2: " ", + content: null, + marcTag: "260", + fieldTag: "p", + subfields: [ + { + tag: "a", + content: "Pavona di Albano Laziale, Roma :", + }, + { + tag: "b", + content: "Boccaccini & Spada,", + }, + { + tag: "c", + content: "c2006.", + }, + ], + }, + }, + ], + }, + { + label: "Format", + values: [ + { + content: "Partitura.", + source: { + ind1: " ", + ind2: " ", + content: null, + marcTag: "254", + fieldTag: "e", + subfields: [ + { + tag: "a", + content: "Partitura.", + }, + ], + }, + }, + ], + }, + { + label: "Description", + values: [ + { + content: "1 score ([ii], 18 p.) ; 31 cm.", + source: { + ind1: " ", + ind2: " ", + content: null, + marcTag: "300", + fieldTag: "r", + subfields: [ + { + tag: "a", + content: "1 score ([ii], 18 p.) ;", + }, + { + tag: "c", + content: "31 cm.", + }, + ], + }, + }, + ], + }, + { + label: "Playing Time", + values: [ + { + content: "000700", + source: { + ind1: " ", + ind2: " ", + content: null, + marcTag: "306", + fieldTag: "r", + subfields: [ + { + tag: "a", + content: "000700", + }, + ], + }, + }, + ], + }, + { + label: "Series", + values: [ + { + content: "Inediti e rarità rossiniane ; 12", + source: { + ind1: "1", + ind2: " ", + content: null, + marcTag: "490", + fieldTag: "s", + subfields: [ + { + tag: "a", + content: "Inediti e rarità rossiniane ;", + }, + { + tag: "v", + content: "12", + }, + ], + }, + }, + { + content: + "Rossini, Gioacchino 1792-1868. Works. Selections (Boccaccini & Spada editore) ; 12.", + source: { + ind1: " ", + ind2: "0", + content: null, + marcTag: "830", + fieldTag: "s", + subfields: [ + { + tag: "a", + content: "Rossini, Gioacchino", + }, + { + tag: "d", + content: "1792-1868.", + }, + { + tag: "t", + content: "Works.", + }, + { + tag: "k", + content: "Selections (Boccaccini & Spada editore) ;", + }, + { + tag: "v", + content: "12.", + }, + ], + }, + }, + ], + }, + { + label: "Note", + values: [ + { + content: "For vocal soloists (TTB) with orchestra.", + source: { + ind1: " ", + ind2: " ", + content: null, + marcTag: "500", + fieldTag: "n", + subfields: [ + { + tag: "a", + content: "For vocal soloists (TTB) with orchestra.", + }, + ], + }, + }, + { + content: + "Preface in Italian with English, French, and German translations.", + source: { + ind1: " ", + ind2: " ", + content: null, + marcTag: "500", + fieldTag: "n", + subfields: [ + { + tag: "a", + content: + "Preface in Italian with English, French, and German translations.", + }, + ], + }, + }, + ], + }, + { + label: "Language", + values: [ + { + content: "Latin words.", + source: { + ind1: " ", + ind2: " ", + content: null, + marcTag: "546", + fieldTag: "n", + subfields: [ + { + tag: "a", + content: "Latin words.", + }, + ], + }, + }, + ], + }, + { + label: "Subject", + values: [ + { + content: "Sacred vocal trios with orchestra -- Scores.", + source: { + ind1: " ", + ind2: "0", + content: null, + marcTag: "650", + fieldTag: "d", + subfields: [ + { + tag: "a", + content: "Sacred vocal trios with orchestra", + }, + { + tag: "v", + content: "Scores.", + }, + ], + }, + }, + ], + }, + { + label: "Added Author", + values: [ + { + content: "Spada, Pietro, 1935- Editor", + source: { + ind1: "1", + ind2: " ", + content: null, + marcTag: "700", + fieldTag: "b", + subfields: [ + { + tag: "a", + content: "Spada, Pietro,", + }, + { + tag: "d", + content: "1935-", + }, + { + tag: "4", + content: "edt", + }, + ], + }, + }, + ], + }, + { + label: "Publisher No.", + values: [ + { + content: "BS. 1607 Boccaccini & Spada", + source: { + ind1: "2", + ind2: "2", + content: null, + marcTag: "028", + fieldTag: "l", + subfields: [ + { + tag: "a", + content: "BS. 1607", + }, + { + tag: "b", + content: "Boccaccini & Spada", + }, + ], + }, + }, + ], + }, + { + label: "Research Call Number", + values: [ + { + content: "JMG 06-1852", + source: { + ind1: "8", + ind2: " ", + content: null, + marcTag: "852", + fieldTag: "q", + subfields: [ + { + tag: "h", + content: "JMG 06-1852", + }, + ], + }, + }, + ], + }, + ], + }, +} + export const bibNoItems = { resource: { "@context": @@ -8971,7 +9642,6 @@ export const noParallels = { publicationStatement: ["[Paris, France] : Gallimard, c2005."], publisherLiteral: ["Gallimard,"], series: ["Childhood"], - seriesStatement: ["Haute enfance"], shelfMark: ["JFC 06-438"], subjectLiteral: [ "Authors, French -- 20th century -- Biography.", @@ -12771,7 +13441,6 @@ export const bibWithSubjectHeadings = { popularity: 1, publicationStatement: ["[Paris, France] : Gallimard, c2005."], publisherLiteral: ["Gallimard"], - seriesStatement: ["Haute enfance"], shelfMark: ["JFC 06-438"], subjectLiteral: [ "Cortanze, Gérard de -- Childhood and youth.", diff --git a/src/components/BibPage/BibDetail.test.tsx b/src/components/BibPage/BibDetail.test.tsx index 190357f08..218a55e41 100644 --- a/src/components/BibPage/BibDetail.test.tsx +++ b/src/components/BibPage/BibDetail.test.tsx @@ -38,7 +38,6 @@ describe("BibDetail component", () => { expect(screen.getByText("French")).toBeInTheDocument() expect(screen.getByText("Series")).toBeInTheDocument() expect(screen.queryAllByText("Childhood")[0]).toBeInTheDocument() - expect(screen.getByText("Series statement")).toBeInTheDocument() expect(screen.queryAllByText(/Haute enfance/)[0]).toBeInTheDocument() }) it("merges annotated MARC and resource fields without label duplicates", () => { @@ -175,8 +174,8 @@ describe("BibDetail component", () => { render(, { wrapper: MemoryRouterProvider, }) - const seriesStatement = screen.getByText("Childhood") - expect(seriesStatement).toHaveAttribute( + const series = screen.getByText("Childhood") + expect(series).toHaveAttribute( "href", expect.stringContaining("/search?filters[series][0]=Childhood") ) diff --git a/src/models/BibDetails.ts b/src/models/BibDetails.ts index 1f51d24cd..210357991 100644 --- a/src/models/BibDetails.ts +++ b/src/models/BibDetails.ts @@ -139,7 +139,7 @@ export default class BibDetails { return { url: getSeriesSearchUrl(name), urlLabel: name, - text: unlinkedText?.trim() || undefined, + text: unlinkedText || undefined, } } ) @@ -338,10 +338,12 @@ export default class BibDetails { resourceEndpointDetails: AnyBibDetail[], annotatedMarcDetails: AnyMarcDetail[] ): AnyBibDetail[] { + // Merge series added entry and series uniform title fields resourceEndpointDetails = this.combineSeriesAddedEntries( resourceEndpointDetails ) + // Normalize and merge bib and annotated marc fields const normalizeValues = (val: any) => { if (!val) return [] if (Array.isArray(val)) { @@ -437,7 +439,6 @@ export default class BibDetails { label: string field: string }): LinkedBibDetail { - console.log(fieldMapping) if (fieldMapping.field === "contributorLiteral") { return this.buildLinkedContributorDetail( "contributorLiteral", diff --git a/src/models/modelTests/BibDetails.test.ts b/src/models/modelTests/BibDetails.test.ts index 0f2c6bfbb..7c23ea8df 100644 --- a/src/models/modelTests/BibDetails.test.ts +++ b/src/models/modelTests/BibDetails.test.ts @@ -9,6 +9,7 @@ import { princetonRecord, bibWithItems, physicalDescriptionBib, + bibWithSeries, } from "../../../__test__/fixtures/bibFixtures" import type { LinkedBibDetail } from "../../types/bibDetailsTypes" import BibDetailsModel from "../BibDetails" @@ -45,6 +46,12 @@ describe("Bib Details model", () => { bibWithSubjectHeadings.resource, bibWithSubjectHeadings.annotatedMarc ) + + const bibWithSeriesModel = new BibDetailsModel( + bibWithSeries.resource, + bibWithSeries.annotatedMarc + ) + describe("owner", () => { it("populates owner when owner is present", () => { const partnerBib = new BibDetailsModel(princetonRecord) @@ -252,6 +259,22 @@ describe("Bib Details model", () => { expect(subjects.link).toBe("internal") expect(subjects.value[0].url).toContain("/browse/subjects/") }) + it("creates series fields and merges series uniform title into series added entry", () => { + const series = bibWithSeriesModel.bottomDetails.find( + (d) => d.label === "Series" + ) as LinkedBibDetail + expect(series.link).toBe("internal") + expect(series.value[0].url).toContain("search?filters[series][0]") + expect(series.value[0].urlLabel).toEqual("Inediti e rarità rossiniane ;") + expect(series.value[0].text).toEqual(" 12") + const seriesAddedEntry = bibWithSeriesModel.bottomDetails.find( + (d) => d.label === "Series added entry" + ) as LinkedBibDetail + // Value is from seriesUniformTitle + expect(seriesAddedEntry.value[0].urlLabel).toEqual( + "Rossini, Gioacchino 1792-1868. Works. Selections (Boccaccini & Spada editore) ;" + ) + }) }) describe("parallels", () => { From ce9f5afd49d5716f507c408f746b0f129fbf4719 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:19:19 -0400 Subject: [PATCH 4/6] Changelog --- CHANGELOG | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 3f37553be..de007ea37 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Updated -- Display non-aggregated filter values [SCC-5125](https://newyorkpubliclibrary.atlassian.net/browse/SCC-5125) +- Displayed non-aggregated filter values [SCC-5125](https://newyorkpubliclibrary.atlassian.net/browse/SCC-5125) +- Updated series links in bib details [SCC-5195](https://newyorkpubliclibrary.atlassian.net/browse/SCC-5195) ## [1.8.2] 2026-04-09 From 24c1e60bee8e09f211e84dcf8bb9b5b39bb05215 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:10:27 -0400 Subject: [PATCH 5/6] Tweak construction of series and bib detail type, update fixtures --- __test__/fixtures/bibFixtures.ts | 1 + src/components/BibPage/BibDetail.tsx | 41 +++++++-- .../HoldPages/HoldRequestItemDetails.tsx | 2 +- src/models/BibDetails.ts | 83 +++++++++---------- src/models/modelTests/BibDetails.test.ts | 14 ++-- src/types/bibDetailsTypes.ts | 4 +- 6 files changed, 85 insertions(+), 60 deletions(-) diff --git a/__test__/fixtures/bibFixtures.ts b/__test__/fixtures/bibFixtures.ts index b688f6451..752237925 100644 --- a/__test__/fixtures/bibFixtures.ts +++ b/__test__/fixtures/bibFixtures.ts @@ -9642,6 +9642,7 @@ export const noParallels = { publicationStatement: ["[Paris, France] : Gallimard, c2005."], publisherLiteral: ["Gallimard,"], series: ["Childhood"], + seriesDisplay: [{ display: "Childhood", "@value": "Childhood" }], shelfMark: ["JFC 06-438"], subjectLiteral: [ "Authors, French -- 20th century -- Biography.", diff --git a/src/components/BibPage/BibDetail.tsx b/src/components/BibPage/BibDetail.tsx index ffc4535db..f9d262bff 100644 --- a/src/components/BibPage/BibDetail.tsx +++ b/src/components/BibPage/BibDetail.tsx @@ -105,13 +105,13 @@ export const BrowseLinkDetailElement = ({ url: `/browse${ browseType === "subjects" ? "" : "/authors/" }?q=${encodeURIComponentWithPeriods( - urlInfo.urlLabel + urlInfo.urlText )}&search_scope=starts_with`, - urlLabel: `[${indexLinkLabel}]`, + urlText: `[${indexLinkLabel}]`, }, "internal", true, - `${indexLinkLabel} for "${urlInfo.urlLabel}"` + `${indexLinkLabel} for "${urlInfo.urlText}"` )} @@ -125,10 +125,12 @@ const LinkElement = ( isBold = false, ariaLabel?: string ) => { - return ( - <> + const { text, urlText, url: href } = url + + if (!text) { + return ( - {url.urlLabel} + {url.urlText} - {url.text && {url.text}} + ) + } + + const parts = text.split(urlText) + + return ( + <> + {parts.map((part, index) => ( + + {part} + {index < parts.length - 1 && ( + + {urlText} + + )} + + ))} ) } diff --git a/src/components/HoldPages/HoldRequestItemDetails.tsx b/src/components/HoldPages/HoldRequestItemDetails.tsx index 7463e1b50..1d7c9faaf 100644 --- a/src/components/HoldPages/HoldRequestItemDetails.tsx +++ b/src/components/HoldPages/HoldRequestItemDetails.tsx @@ -25,7 +25,7 @@ const HoldRequestItemDetails = ({ item }: HoldRequestItemDetailsProps) => { > {LinkedDetailElement({ label: "Title", - value: [{ url: `${PATHS.BIB}/${item.bibId}`, urlLabel: item.bibTitle }], + value: [{ url: `${PATHS.BIB}/${item.bibId}`, urlText: item.bibTitle }], link: "internal", })} diff --git a/src/models/BibDetails.ts b/src/models/BibDetails.ts index 210357991..faa865d6f 100644 --- a/src/models/BibDetails.ts +++ b/src/models/BibDetails.ts @@ -81,44 +81,42 @@ export default class BibDetails { const mapDisplay = (arr?: DisplayPackedEntry[]): BibDetailURL[] => Array.isArray(arr) ? arr.map(({ display, "@value": name }) => { - const [, roles] = display.split(name) return { url: getContributorSearchURL(name), - urlLabel: name, - text: roles?.trim() || undefined, + urlText: name, + text: display, } }) : [] const displayValues = mapDisplay(this.bib[displayField]) - // Set of displayed names for deduping - const seen = new Set(displayValues.map((v) => v.urlLabel)) - // Literals (fallback) const literalValues: string[] = this.bib[literalField] || [] - const literals: BibDetailURL[] = literalValues - .filter((name) => !seen.has(name)) - .map((name) => ({ - url: getContributorSearchURL(name), - urlLabel: name, - })) - - const combinedValues = [...displayValues, ...literals] + const literals: BibDetailURL[] = literalValues.map((name) => ({ + url: getContributorSearchURL(name), + urlText: name, + })) - return combinedValues?.length > 0 + return displayValues?.length > 0 ? { label, link: "internal", - value: combinedValues, + value: displayValues, + } + : literals?.length > 0 + ? { + label, + link: "internal", + value: literals, } : null } - buildLinkedSeriesDetail(field): LinkedBibDetail | null { + buildLinkedSeriesDetail(literalField): LinkedBibDetail | null { let displayField = "seriesDisplay" let label = "Series" - switch (field) { + switch (literalField) { case "seriesAddedEntry": displayField = "seriesAddedEntryDisplay" label = "Series added entry" @@ -135,33 +133,34 @@ export default class BibDetails { const displayData: DisplayPackedEntry[] = this.bib[displayField] || [] const displayValues: BibDetailURL[] = displayData.map( ({ display, "@value": name }) => { - const [, unlinkedText] = display.split(name) return { url: getSeriesSearchUrl(name), - urlLabel: name, - text: unlinkedText || undefined, + urlText: name, + text: display, } } ) - // Set of field values for deduping - const seen = new Set( - displayData.map((v) => - field === "seriesAddedEntry" ? v.display : v["@value"] - ) - ) - // Literals (fallback) - const literals: BibDetailURL[] = (this.bib[field] || []) - .filter((name) => !seen.has(name)) - .map((name) => ({ - url: getSeriesSearchUrl(name), - urlLabel: name, - })) - - const value = [...displayValues, ...literals] + const literalValues: string[] = this.bib[literalField] || [] + const literals: BibDetailURL[] = literalValues.map((name) => ({ + url: getSeriesSearchUrl(name), + urlText: name, + })) - return value.length ? { label, link: "internal", value } : null + return displayValues?.length > 0 + ? { + label, + link: "internal", + value: displayValues, + } + : literals?.length > 0 + ? { + label, + link: "internal", + value: literals, + } + : null } buildAnnotatedMarcDetails( @@ -173,7 +172,7 @@ export default class BibDetails { if (label === "Connect to:") { const urlValues = values.map(({ label, content }) => ({ url: content, - urlLabel: label, + urlText: label, })) const detail = this.buildExternalLinkedDetail( "Connect to:", @@ -352,12 +351,12 @@ export default class BibDetails { .map((v) => typeof v === "string" ? v.trim() - : v?.content?.trim() || v?.urlLabel?.trim() + : v?.content?.trim() || v?.urlText?.trim() ) } if (typeof val === "string") return [val.trim()] if (val?.content) return [val.content.trim()] - return [val?.urlLabel?.trim()] + return [val?.urlText?.trim()] } const labelsSet = new Set(resourceEndpointDetails.map((d) => d.label)) @@ -471,7 +470,7 @@ export default class BibDetails { v )}` } - return { url: internalUrl, urlLabel: v } + return { url: internalUrl, urlText: v } }), } } @@ -603,7 +602,7 @@ export default class BibDetails { }) .map((sc) => ({ url: sc.url, - urlLabel: sc.label, + urlText: sc.label, })) return this.buildExternalLinkedDetail(convertToSentenceCase(label), values) } diff --git a/src/models/modelTests/BibDetails.test.ts b/src/models/modelTests/BibDetails.test.ts index 7c23ea8df..08d33a21e 100644 --- a/src/models/modelTests/BibDetails.test.ts +++ b/src/models/modelTests/BibDetails.test.ts @@ -164,7 +164,7 @@ describe("Bib Details model", () => { label: "Supplementary content", value: [ { - urlLabel: "Image", + urlText: "Image", url: "http://images.contentreserve.com/ImageType-100/0293-1/{C87D2BB9-0E13-4851-A9E2-547643F41A0E}Img100.jpg", }, ], @@ -177,7 +177,7 @@ describe("Bib Details model", () => { label: "Supplementary content", value: [ { - urlLabel: "Image", + urlText: "Image", url: "http://images.contentreserve.com/ImageType-100/0293-1/{C87D2BB9-0E13-4851-A9E2-547643F41A0E}Img100.jpg", }, ], @@ -210,7 +210,7 @@ describe("Bib Details model", () => { value: [ { url: "/search?filters[placeOfPublication][0]=Mansfield%2C%20Ohio", - urlLabel: "Mansfield, Ohio", + urlText: "Mansfield, Ohio", }, ], }, @@ -225,7 +225,7 @@ describe("Bib Details model", () => { value: [ { url: "/search?filters[donor][0]=Gift%20of%20the%20DeWitt%20Wallace%20Endowment%20Fund%2C%20named%20in%20honor%20of%20the%20founder%20of%20Reader's%20Digest", - urlLabel: + urlText: "Gift of the DeWitt Wallace Endowment Fund, named in honor of the founder of Reader's Digest", }, ], @@ -265,13 +265,13 @@ describe("Bib Details model", () => { ) as LinkedBibDetail expect(series.link).toBe("internal") expect(series.value[0].url).toContain("search?filters[series][0]") - expect(series.value[0].urlLabel).toEqual("Inediti e rarità rossiniane ;") - expect(series.value[0].text).toEqual(" 12") + expect(series.value[0].urlText).toEqual("Inediti e rarità rossiniane ;") + expect(series.value[0].text).toEqual("Inediti e rarità rossiniane ; 12") const seriesAddedEntry = bibWithSeriesModel.bottomDetails.find( (d) => d.label === "Series added entry" ) as LinkedBibDetail // Value is from seriesUniformTitle - expect(seriesAddedEntry.value[0].urlLabel).toEqual( + expect(seriesAddedEntry.value[0].urlText).toEqual( "Rossini, Gioacchino 1792-1868. Works. Selections (Boccaccini & Spada editore) ;" ) }) diff --git a/src/types/bibDetailsTypes.ts b/src/types/bibDetailsTypes.ts index 0f6d09d21..eb47555dc 100644 --- a/src/types/bibDetailsTypes.ts +++ b/src/types/bibDetailsTypes.ts @@ -38,8 +38,8 @@ export interface MarcLinkedDetail { export interface BibDetailURL { url: string - urlLabel?: string - // Unlinked text following the URL + urlText?: string + // Unlinked text surrounding (and including) the linked urlText text?: string } From 15d185fc88e5fdaa4eba702481bf106c612a1cdf Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 16 Apr 2026 17:13:25 -0400 Subject: [PATCH 6/6] comments --- src/models/BibDetails.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/models/BibDetails.ts b/src/models/BibDetails.ts index faa865d6f..b65e838e7 100644 --- a/src/models/BibDetails.ts +++ b/src/models/BibDetails.ts @@ -310,8 +310,7 @@ export default class BibDetails { ) } - // Series uniform title values should display under Series added entry, - // combine these details + // Series uniform title values display under Series added entry combineSeriesAddedEntries(resourceEndpointDetails: AnyBibDetail[]) { const addedEntry = resourceEndpointDetails.find( (d) => d.label === "Series added entry" @@ -337,7 +336,7 @@ export default class BibDetails { resourceEndpointDetails: AnyBibDetail[], annotatedMarcDetails: AnyMarcDetail[] ): AnyBibDetail[] { - // Merge series added entry and series uniform title fields + // Merge Series added entry and Series uniform title fields resourceEndpointDetails = this.combineSeriesAddedEntries( resourceEndpointDetails )