diff --git a/.env.example b/.env.example
index ad3b49cdf..6b953d20a 100644
--- a/.env.example
+++ b/.env.example
@@ -2,9 +2,6 @@ NEXT_PUBLIC_APP_ENV="development"
PLATFORM_API_CLIENT_ID=[secret]
PLATFORM_API_CLIENT_SECRET=[secret]
NYPL_HEADER_URL=https://ds-header.nypl.org
-CLOSED_LOCATIONS=""
-RECAP_CLOSED_LOCATIONS=""
-NON_RECAP_CLOSED_LOCATIONS=""
ADOBE_EMBED_URL=https://assets.adobedtm.com/1a9376472d37/ddf1bedfe52e/launch-4eefcc91c90e.min.js
SHEP_API=http://[fqdn]/api/v0.1
SIERRA_KEY=[secret]
diff --git a/CHANGELOG b/CHANGELOG
index 635dc2c4f..4d86ee017 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## Prerelease
+
+## Updated
+
+- Better handling of bibs with no titles in SearchResultsBib model
+
## 1.3.5 2024-10-23
### Updated
@@ -14,7 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Call number search scope to search dropdown options [(SCC-4260)](https://newyorkpubliclibrary.atlassian.net/browse/SCC-4260)
- Search tip per search scope [(SCC-4263)](https://newyorkpubliclibrary.atlassian.net/browse/SCC-4263)
+### Added
+
+- Add server side delivery location fetch and location utils (SCC-3759)
+
+### Updated
+
+- Moved closedLocations from environment variables to appConfig (SCC-3759)
+
### Fixed
+
### [1.3.4] Hotfix 2024-10-17
- Fix 500 error caused by assumption in buildHoldingDetails that location field will be present in the bib holdings returned from discovery (SCC-4314)
diff --git a/ENVIRONMENTVARS.md b/ENVIRONMENTVARS.md
index 39a714c39..610f3a7ee 100644
--- a/ENVIRONMENTVARS.md
+++ b/ENVIRONMENTVARS.md
@@ -20,21 +20,18 @@ If an environment variable is updated, make sure to restart the server for the a
These environment variables control how certain elements on the page render and where to fetch data.
-| Variable | Type | Value Example | Description |
-| ----------------------------------- | ------- | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `NEXT_PUBLIC_APP_ENV` | string | "development" | App environment key used to determine various environment-specific app settings. |
-| `NYPL_HEADER_URL` | string | "https://ds-header.nypl.org" | The base URL of the NYPL environment-specific header and footer scripts. |
-| `CLOSED_LOCATIONS` | string | "all;Library of the Performing Arts" | A semicolon-delimited list of strings. Include quotes around the string. All locations beginning with any string in this list will be removed from the list of request options in the `ElectronicDelivery`, `HoldRequest`, and `ItemTableRow` components. Currently used physical locations: `Schwarzman;Science;Library for the Performing Arts;Schomburg`. To close all locations, add `all`. This will also remove EDD as a request option, the 'Request' buttons, and also disable the hold request/edd forms. If `all` is not present, EDD and 'Request' buttons will still be available. |
-| `RECAP_CLOSED_LOCATIONS` | string | "" | A semicolon-delimited list of closed locations that are recap. |
-| `NON_RECAP_CLOSED_LOCATIONS` | string | "" | A semicolon-delimited list of closed locations that are not recap. |
-| `ADOBE_EMBED_URL` | string | "" | Url endpoint used for Adobe Analytics event tracking. |
-| `SHEP_API` | string | "" | SHEP API endpoint used for fetching Subject Heading data |
-| `SEARCH_RESULTS_NOTIFICATION` | string | "Due to winter holiday closures, the delivery time for off-site requests will be delayed..." | A string that can include HTML that will be rendered as a notification on the Home and Search Results pages. |
-| `LOGIN_BASE_URL` | string | "" | The base URL used to construct the environment-dependent login/logout link. |
-| `NEXT_PUBLIC_REVERSE_PROXY_ENABLED` | boolean | true | Feature flag that disables Next router navigation on Searches to fix issues navigating between research-catalog and discovery-front-end |
-| `SIERRA_BASE` | string | "" | Sierra base url |
-| `SOURCE_EMAIL` | string | "" | Default source email used in feedback form submissions |
-| `LIB_ANSWERS_EMAIL` | string | "" | Destination email for feedback form submissions |
+| Variable | Type | Value Example | Description |
+| ----------------------------------- | ------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
+| `NEXT_PUBLIC_APP_ENV` | string | "development" | App environment key used to determine various environment-specific app settings. |
+| `NYPL_HEADER_URL` | string | "https://ds-header.nypl.org" | The base URL of the NYPL environment-specific header and footer scripts. |
+| `ADOBE_EMBED_URL` | string | "" | Url endpoint used for Adobe Analytics event tracking. |
+| `SHEP_API` | string | "" | SHEP API endpoint used for fetching Subject Heading data |
+| `SEARCH_RESULTS_NOTIFICATION` | string | "Due to winter holiday closures, the delivery time for off-site requests will be delayed..." | A string that can include HTML that will be rendered as a notification on the Home and Search Results pages. |
+| `LOGIN_BASE_URL` | string | "" | The base URL used to construct the environment-dependent login/logout link. |
+| `NEXT_PUBLIC_REVERSE_PROXY_ENABLED` | boolean | true | Feature flag that disables Next router navigation on Searches to fix issues navigating between research-catalog and discovery-front-end |
+| `SIERRA_BASE` | string | "" | Sierra base url |
+| `SOURCE_EMAIL` | string | "" | Default source email used in feedback form submissions |
+| `LIB_ANSWERS_EMAIL` | string | "" | Destination email for feedback form submissions |
## AWS ECS Environment Variables
diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx
index 06dc44e35..656320f8d 100644
--- a/src/components/Layout/Layout.tsx
+++ b/src/components/Layout/Layout.tsx
@@ -88,7 +88,7 @@ const Layout = ({
padding: "2em 2em .5em 2em",
}}
>
-
+ {showSearch && }
{showNotification && bannerNotification && (
{
{ "@id": "urn:biblevel:s", prefLabel: "serial" },
])
})
+ it("defaults to title display when no title", () => {
+ const bibWithNoTitle = new SearchResultsBib({
+ ...bibWithItems.resource,
+ title: undefined,
+ titleDisplay: ["Display title"],
+ })
+ expect(bibWithNoTitle.title).toBe("Display title")
+ })
+ it("defaults to '[Untitled]' when no title or titleDisplay", () => {
+ const bibWithNoTitle = new SearchResultsBib({
+ ...bibWithItems.resource,
+ title: undefined,
+ titleDisplay: undefined,
+ })
+ expect(bibWithNoTitle.title).toBe("[Untitled]")
+ })
it("initializes the yearPublished based on dateStartYear and dateEndYear", () => {
expect(searchResultsBib.yearPublished).toBe("1999-present")
diff --git a/src/server/api/hold.ts b/src/server/api/hold.ts
new file mode 100644
index 000000000..241104a06
--- /dev/null
+++ b/src/server/api/hold.ts
@@ -0,0 +1,61 @@
+import nyplApiClient from "../nyplApiClient"
+import type {
+ DeliveryLocation,
+ DeliveryLocationsResponse,
+ DiscoveryLocationElement,
+} from "../../types/locationTypes"
+import {
+ mapLocationElementToDeliveryLocation,
+ locationIsClosed,
+} from "../../utils/locationUtils"
+import { appConfig } from "../../config/config"
+
+/**
+ * Getter function for hold delivery locations.
+ */
+export async function fetchDeliveryLocations(
+ barcode: string,
+ patronId: string
+): Promise {
+ const deliveryEndpoint = `/request/deliveryLocationsByBarcode?barcodes[]=${barcode}&patronId=${patronId}`
+
+ try {
+ const client = await nyplApiClient()
+ const discoveryLocationsResult = await client.get(deliveryEndpoint)
+ const discoveryLocationsItem =
+ discoveryLocationsResult?.itemListElement?.[0]
+
+ // Malformed response
+ if (!discoveryLocationsItem) {
+ throw new Error("Malformed response from delivery locations API")
+ }
+
+ const deliveryLocations = discoveryLocationsItem?.deliveryLocation.map(
+ (locationElement: DiscoveryLocationElement) =>
+ mapLocationElementToDeliveryLocation(locationElement)
+ )
+ const eddRequestable = discoveryLocationsItem.eddRequestable || false
+
+ // Filter out closed locations
+ const openLocations = deliveryLocations.filter(
+ (location: DeliveryLocation) =>
+ !locationIsClosed(location, appConfig.closedLocations)
+ )
+
+ /**
+ * Locations are returned with a status of 200.
+ * deliveryLocations can be an empty array if there are no open locations.
+ */
+ return {
+ deliveryLocations: openLocations,
+ eddRequestable,
+ status: 200,
+ }
+ } catch (error) {
+ console.error(`Error fetching delivery locations ${error.message}`)
+
+ return {
+ status: 500,
+ }
+ }
+}
diff --git a/src/types/locationTypes.ts b/src/types/locationTypes.ts
new file mode 100644
index 000000000..a72876123
--- /dev/null
+++ b/src/types/locationTypes.ts
@@ -0,0 +1,33 @@
+import type { HTTPStatusCode } from "./appTypes"
+
+export interface DeliveryLocationsResponse {
+ deliveryLocations?: DeliveryLocation[]
+ eddRequestable?: boolean
+ status: HTTPStatusCode
+}
+
+export interface DeliveryLocation {
+ key: NYPLocationKey
+ address: string
+ shortName: string
+ label: string
+}
+
+export type NYPLocationKey = "lpa" | "schwarzman" | "schomburg"
+export type RecapLocationKey = string
+
+export interface DiscoveryLocationsResult {
+ itemListElement?: DiscoveryLocationItem[]
+}
+
+export interface DiscoveryLocationItem {
+ eddRequestable?: boolean
+ deliveryLocation?: DiscoveryLocationElement[]
+}
+
+/* eslint-disable @typescript-eslint/naming-convention */
+
+export interface DiscoveryLocationElement {
+ "@id"?: string
+ prefLabel?: string
+}
diff --git a/src/utils/itemUtils.ts b/src/utils/itemUtils.ts
index b4ee69b23..7366c7436 100644
--- a/src/utils/itemUtils.ts
+++ b/src/utils/itemUtils.ts
@@ -26,10 +26,3 @@ export const locationEndpointsMap: Record = {
export function locationLabelToKey(label: string): ItemLocationKey {
return label.replace(/SASB/, "Schwarzman").split(" ")[0] as ItemLocationKey
}
-
-/**
- * parseLocations
- * Takes a semicolon-separated list of locations set in an ENV variable and maps them to an array.
- */
-export const parseLocations = (locations: string): string[] =>
- locations ? locations.split(";") : []
diff --git a/src/utils/locationUtils.ts b/src/utils/locationUtils.ts
new file mode 100644
index 000000000..4e55d5fb1
--- /dev/null
+++ b/src/utils/locationUtils.ts
@@ -0,0 +1,74 @@
+import type {
+ DiscoveryLocationElement,
+ DeliveryLocation,
+ NYPLocationKey,
+} from "../types/locationTypes"
+import { NYPL_LOCATIONS } from "../config/constants"
+
+/**
+ * Maps a single location element from the discovery API response to a DeliveryLocation object.
+ */
+export const mapLocationElementToDeliveryLocation = (
+ locationElement: DiscoveryLocationElement
+): DeliveryLocation => {
+ const locationKey = getLocationKey(locationElement)
+ const details = NYPL_LOCATIONS[locationKey]
+
+ if (!details) return null
+
+ const shortName = details.shortName
+
+ // LPA locations require label tranformation
+ const label =
+ locationKey === "lpa"
+ ? formatDeliveryLocationLabel(locationElement.prefLabel, shortName)
+ : locationElement.prefLabel
+
+ return {
+ key: locationKey,
+ address: details.address,
+ shortName,
+ label,
+ }
+}
+
+function formatDeliveryLocationLabel(
+ prefLabel: string,
+ shortName: string
+): string {
+ const deliveryRoom = prefLabel?.split(" - ")[1] || ""
+
+ return `${shortName}${deliveryRoom ? ` - ${deliveryRoom}` : ""}`
+}
+
+function getLocationKey(
+ locationElement: DiscoveryLocationElement
+): NYPLocationKey | null {
+ if (!locationElement?.["@id"]) return null
+
+ const sierraId = getLocationSierraId(locationElement)
+
+ switch (sierraId?.slice(0, 2)) {
+ case "pa":
+ return "lpa"
+ case "ma":
+ return "schwarzman"
+ case "sc":
+ return "schomburg"
+ default:
+ return null
+ }
+}
+
+const getLocationSierraId = (
+ locationElement: DiscoveryLocationElement
+): string | null =>
+ locationElement["@id"] ? locationElement["@id"].replace("loc:", "") : null
+
+export const locationIsClosed = (
+ deliveryLocation: DeliveryLocation,
+ closedLocations: string[]
+): boolean =>
+ closedLocations.some(
+ (closedLocationKey) => deliveryLocation.key === closedLocationKey
+ ) || closedLocations.includes("all")
diff --git a/src/utils/utilsTests/itemUtils.ts b/src/utils/utilsTests/itemUtils.ts
index 359bc9a1e..db33aed56 100644
--- a/src/utils/utilsTests/itemUtils.ts
+++ b/src/utils/utilsTests/itemUtils.ts
@@ -1,4 +1,4 @@
-import { locationLabelToKey, parseLocations } from "../itemUtils"
+import { locationLabelToKey } from "../itemUtils"
describe("itemUtils", () => {
describe("locationLabelToKey", () => {
@@ -8,17 +8,4 @@ describe("itemUtils", () => {
expect(locationLabelToKey("Schomburg")).toBe("schomburg")
})
})
- describe("parseLocations", () => {
- it("splits a semicolon separated list of locations into an array", () => {
- expect(parseLocations("schwarzman;lpa")).toBe(["schwarzman", "lpa"])
- })
- it("returns a single value array when no semicolon delineator is present", () => {
- expect(parseLocations("schomburg")).toBe(["schomburg"])
- })
- it("returns an empty array when locations is falsy", () => {
- expect(parseLocations("")).toBe([])
- expect(parseLocations(null)).toBe([])
- expect(parseLocations(undefined)).toBe([])
- })
- })
})
diff --git a/src/utils/utilsTests/locationUtils.test.ts b/src/utils/utilsTests/locationUtils.test.ts
new file mode 100644
index 000000000..e8cb1d77c
--- /dev/null
+++ b/src/utils/utilsTests/locationUtils.test.ts
@@ -0,0 +1,202 @@
+import type {
+ DiscoveryLocationElement,
+ NYPLocationKey,
+} from "../../types/locationTypes"
+import { NYPL_LOCATIONS } from "../../config/constants"
+
+import {
+ mapLocationElementToDeliveryLocation,
+ locationIsClosed,
+} from "../locationUtils"
+
+describe("itemUtils", () => {
+ describe("mapLocationElementToDeliveryLocation", () => {
+ it("maps single location response elements with different location slugs to the correctly formatted DeliveryLocation type", () => {
+ // mal17
+ let locationElement: DiscoveryLocationElement = {
+ "@id": "loc:mal17",
+ prefLabel: "Schwarzman Building - Scholar Room 217",
+ }
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schwarzman",
+ address: NYPL_LOCATIONS["schwarzman"].address,
+ label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Scholar Room 217`,
+ shortName: NYPL_LOCATIONS["schwarzman"].shortName,
+ })
+
+ // mal
+ locationElement = {
+ "@id": "loc:mal",
+ prefLabel: "Schwarzman Building - Main Reading Room 315",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schwarzman",
+ address: NYPL_LOCATIONS["schwarzman"].address,
+ label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Main Reading Room 315`,
+ shortName: NYPL_LOCATIONS["schwarzman"].shortName,
+ })
+
+ // mab
+ locationElement = {
+ "@id": "loc:mab",
+ prefLabel: "Schwarzman Building - Art & Architecture Room 300",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schwarzman",
+ address: NYPL_LOCATIONS["schwarzman"].address,
+ label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Art & Architecture Room 300`,
+ shortName: NYPL_LOCATIONS["schwarzman"].shortName,
+ })
+
+ // maf
+ locationElement = {
+ "@id": "loc:maf",
+ prefLabel: "Schwarzman Building - Dorot Jewish Division Room 111",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schwarzman",
+ address: NYPL_LOCATIONS["schwarzman"].address,
+ label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Dorot Jewish Division Room 111`,
+ shortName: NYPL_LOCATIONS["schwarzman"].shortName,
+ })
+
+ // maf
+ locationElement = {
+ "@id": "loc:map",
+ prefLabel: "Schwarzman Building - Map Division Room 117",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schwarzman",
+ address: NYPL_LOCATIONS["schwarzman"].address,
+ label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Map Division Room 117`,
+ shortName: NYPL_LOCATIONS["schwarzman"].shortName,
+ })
+
+ // map
+ locationElement = {
+ "@id": "loc:map",
+ prefLabel: "Schwarzman Building - Map Division Room 117",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schwarzman",
+ address: NYPL_LOCATIONS["schwarzman"].address,
+ label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Map Division Room 117`,
+ shortName: NYPL_LOCATIONS["schwarzman"].shortName,
+ })
+
+ // mag
+ locationElement = {
+ "@id": "loc:mag",
+ prefLabel: "Schwarzman Building - Milstein Division Room 121",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schwarzman",
+ address: NYPL_LOCATIONS["schwarzman"].address,
+ label: `${NYPL_LOCATIONS["schwarzman"].shortName} - Milstein Division Room 121`,
+ shortName: NYPL_LOCATIONS["schwarzman"].shortName,
+ })
+
+ // par
+ locationElement = {
+ "@id": "loc:par",
+ prefLabel: "Performing Arts Research Collections",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "lpa",
+ address: NYPL_LOCATIONS["lpa"].address,
+ label: NYPL_LOCATIONS["lpa"].shortName,
+ shortName: NYPL_LOCATIONS["lpa"].shortName,
+ })
+
+ // sc
+ locationElement = {
+ "@id": "loc:sc",
+ prefLabel: "Schomburg Center - Research and Reference Division",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schomburg",
+ address: NYPL_LOCATIONS["schomburg"].address,
+ label: `${NYPL_LOCATIONS["schomburg"].shortName} - Research and Reference Division`,
+ shortName: NYPL_LOCATIONS["schomburg"].shortName,
+ })
+ })
+ it("returns null when the location is not found in the location details mapping found in this repo", () => {
+ const locationElement: DiscoveryLocationElement = {
+ "@id": "loc:spaghetti",
+ prefLabel: "Spaghetti Building - Scholar Room 1000",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual(
+ null
+ )
+ })
+ it("returns null when the the id is absent", () => {
+ const locationElement: DiscoveryLocationElement = {
+ prefLabel: "Spaghetti Building - Scholar Room 217",
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual(
+ null
+ )
+ })
+
+ it("doesn't add the delivery room when the prefLabel doesn't include a dash", () => {
+ const locationElement: DiscoveryLocationElement = {
+ "@id": "loc:sc",
+ prefLabel: NYPL_LOCATIONS["schomburg"].shortName,
+ }
+
+ expect(mapLocationElementToDeliveryLocation(locationElement)).toEqual({
+ key: "schomburg",
+ address: NYPL_LOCATIONS["schomburg"].address,
+ label: NYPL_LOCATIONS["schomburg"].shortName,
+ shortName: NYPL_LOCATIONS["schomburg"].shortName,
+ })
+ })
+ })
+
+ describe("locationIsClosed", () => {
+ it("determines if the location is closed based on the key and the closedLocations array set in config", () => {
+ let closedLocations = []
+ const deliveryLocation = {
+ key: "schwarzman" as NYPLocationKey,
+ address: "476 Fifth Avenue (42nd St and Fifth Ave)",
+ label: "Schwarzman Building - Scholar Room 217",
+ shortName: "Schwarzman Building",
+ }
+
+ expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(false)
+
+ closedLocations = ["schwarzman"]
+ expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(true)
+ })
+ it("always returns true if 'all' is in the closedLocations array", () => {
+ let closedLocations = ["all"]
+ let deliveryLocation = {
+ key: "schwarzman" as NYPLocationKey,
+ address: "476 Fifth Avenue (42nd St and Fifth Ave)",
+ label: "Schwarzman Building - Scholar Room 217",
+ shortName: "Schwarzman Building",
+ }
+
+ expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(true)
+
+ closedLocations = ["Schwarzman", "all"]
+ deliveryLocation = {
+ key: "lpa" as NYPLocationKey,
+ address: "40 Lincoln Center Plaza",
+ label: "Library for the Performing Arts",
+ shortName: "Library for the Performing Arts",
+ }
+ expect(locationIsClosed(deliveryLocation, closedLocations)).toEqual(true)
+ })
+ })
+})