diff --git a/cypress/fixtures/material/open-order/fbi-api.json b/cypress/fixtures/material/open-order/fbi-api.json index f20b7de75e..ff277f9ec0 100644 --- a/cypress/fixtures/material/open-order/fbi-api.json +++ b/cypress/fixtures/material/open-order/fbi-api.json @@ -1,31 +1,31 @@ { "data": { "work": { - "workId": "work-of:870970-basis:135721719", + "workId": "work-of:870970-basis:44926407", "titles": { "full": [ - "Restyle & restitch for little ones : 30 simple projects from preloved clothes" + "Duchess of Death : the unauthorized biography of Agatha Christie" ], "original": [] }, "abstract": [ - "30 sewing projects for creating stylish baby clothes, from newborns to two-year-olds, including trousers, dresses, sweaters, mittens, rompers and socks. The projects are accompanied by colourful, full-size patterns to help you make the most of your repurposed clothing." + "Summary: Utilizing over 5,000 previously unpublished letters, notes, and documents, the author tells how Christie's life was \"as full of romance, travel, wealth, and scandal as any mystery Christie ever crafted.\" -- from publisher description." ], "creators": [ { - "display": "Linnea Larsson", + "display": "Richard Hack", "__typename": "Person" } ], "series": [], "seriesMembers": [], "workYear": null, - "genreAndForm": ["snitmønstre", "vejledninger"], + "genreAndForm": ["biografier", "bibliografier"], "manifestations": { "all": [ { - "pid": "870970-basis:135721719", - "genreAndForm": ["snitmønstre", "vejledninger"], + "pid": "870970-basis:44926407", + "genreAndForm": ["biografier"], "source": ["Bibliotekskatalog"], "languages": { "main": [ @@ -36,7 +36,7 @@ ] }, "titles": { - "main": ["Restyle & restitch for little ones"], + "main": ["Duchess of Death"], "original": [] }, "fictionNonfiction": { @@ -50,21 +50,21 @@ ], "creators": [ { - "display": "Linnea Larsson", + "display": "Richard Hack", "__typename": "Person" } ], - "publisher": ["Search"], + "publisher": ["JR Books"], "identifiers": [ { - "value": "9781800921191" + "value": "9781906779832" } ], "contributors": [], "edition": { - "summary": "2023", + "summary": "1. edition, 2010", "publicationYear": { - "display": "2023" + "display": "2010" } }, "dateFirstEdition": null, @@ -73,7 +73,7 @@ }, "physicalDescriptions": [ { - "numberOfPages": 124, + "numberOfPages": 325, "playingTime": null } ], @@ -89,19 +89,19 @@ } ], "shelfmark": { - "postfix": "Larsson", - "shelfmark": "64.62" + "postfix": "Hack", + "shelfmark": "99.4 Christie, Agatha" }, "workYear": null, "catalogueCodes": { "nationalBibliography": [], - "otherCatalogues": ["OVE202314"] + "otherCatalogues": ["OVE999999"] } } ], "latest": { - "pid": "870970-basis:135721719", - "genreAndForm": ["snitmønstre", "vejledninger"], + "pid": "870970-basis:44926407", + "genreAndForm": ["biografier"], "source": ["Bibliotekskatalog"], "languages": { "main": [ @@ -112,7 +112,7 @@ ] }, "titles": { - "main": ["Restyle & restitch for little ones"], + "main": ["Duchess of Death"], "original": [] }, "fictionNonfiction": { @@ -126,21 +126,21 @@ ], "creators": [ { - "display": "Linnea Larsson", + "display": "Richard Hack", "__typename": "Person" } ], - "publisher": ["Search"], + "publisher": ["JR Books"], "identifiers": [ { - "value": "9781800921191" + "value": "9781906779832" } ], "contributors": [], "edition": { - "summary": "2023", + "summary": "1. edition, 2010", "publicationYear": { - "display": "2023" + "display": "2010" } }, "dateFirstEdition": null, @@ -149,7 +149,7 @@ }, "physicalDescriptions": [ { - "numberOfPages": 124, + "numberOfPages": 325, "playingTime": null } ], @@ -165,18 +165,18 @@ } ], "shelfmark": { - "postfix": "Larsson", - "shelfmark": "64.62" + "postfix": "Hack", + "shelfmark": "99.4 Christie, Agatha" }, "workYear": null, "catalogueCodes": { "nationalBibliography": [], - "otherCatalogues": ["OVE202314"] + "otherCatalogues": ["OVE999999"] } }, "bestRepresentation": { - "pid": "870970-basis:135721719", - "genreAndForm": ["snitmønstre", "vejledninger"], + "pid": "870970-basis:44926407", + "genreAndForm": ["biografier"], "source": ["Bibliotekskatalog"], "languages": { "main": [ @@ -187,7 +187,7 @@ ] }, "titles": { - "main": ["Restyle & restitch for little ones"], + "main": ["Duchess of Death"], "original": [] }, "fictionNonfiction": { @@ -201,21 +201,21 @@ ], "creators": [ { - "display": "Linnea Larsson", + "display": "Richard Hack", "__typename": "Person" } ], - "publisher": ["Search"], + "publisher": ["JR Books"], "identifiers": [ { - "value": "9781800921191" + "value": "9781906779832" } ], "contributors": [], "edition": { - "summary": "2023", + "summary": "1. edition, 2010", "publicationYear": { - "display": "2023" + "display": "2010" } }, "dateFirstEdition": null, @@ -224,7 +224,7 @@ }, "physicalDescriptions": [ { - "numberOfPages": 124, + "numberOfPages": 325, "playingTime": null } ], @@ -240,13 +240,13 @@ } ], "shelfmark": { - "postfix": "Larsson", - "shelfmark": "64.62" + "postfix": "Hack", + "shelfmark": "99.4 Christie, Agatha" }, "workYear": null, "catalogueCodes": { "nationalBibliography": [], - "otherCatalogues": ["OVE202314"] + "otherCatalogues": ["OVE999999"] } } }, @@ -264,42 +264,45 @@ "subjects": { "all": [ { - "display": "syning" + "display": "forfattere" }, { - "display": "børnetøj" + "display": "kriminalforfattere" }, { - "display": "genbrug" + "display": "England" }, { - "display": "genbrugstøj" + "display": "Authors, English -- 20th century -- Biography" }, { - "display": "tekstiler" + "display": "Detective and mystery stories -- Authorship" }, { - "display": "upcycling" + "display": "Agatha Christie (1890-1976)" + }, + { + "display": "Agatha Christie" + }, + { + "display": "1900-tallet" } ], "dbcVerified": [ { - "display": "syning" - }, - { - "display": "børnetøj" + "display": "forfattere" }, { - "display": "genbrug" + "display": "kriminalforfattere" }, { - "display": "genbrugstøj" + "display": "England" }, { - "display": "tekstiler" + "display": "Agatha Christie" }, { - "display": "upcycling" + "display": "1900-tallet" } ] }, @@ -308,7 +311,7 @@ "code": "NONFICTION" }, "dk5MainEntry": { - "display": "64.62 Syning af børnetøj" + "display": "99.4 Christie, Agatha" }, "relations": { "hasReview": [], diff --git a/src/apps/material/material.dev.tsx b/src/apps/material/material.dev.tsx index 85dd317487..602da32c18 100644 --- a/src/apps/material/material.dev.tsx +++ b/src/apps/material/material.dev.tsx @@ -817,11 +817,6 @@ export default { defaultValue: "Order from another library:", control: { type: "text" } }, - openOrderResponseIsReservedForYouText: { - name: "Reservation Success Title", - defaultValue: "is ordered to your library", - control: { type: "text" } - }, openOrderAuthenticationErrorText: { name: "Open order authentication error text", defaultValue: "Authentication error occurred", @@ -975,5 +970,5 @@ Underverden.args = { export const overbygningsMatriale = Template.bind({}); overbygningsMatriale.args = { - wid: "work-of:870970-basis:135721719" + wid: "work-of:870970-basis:44926407" }; diff --git a/src/apps/material/material.entry.tsx b/src/apps/material/material.entry.tsx index 1295ffa494..69090a213b 100644 --- a/src/apps/material/material.entry.tsx +++ b/src/apps/material/material.entry.tsx @@ -118,7 +118,6 @@ interface MaterialEntryTextProps { openOrderOrsErrorText: string; openOrderOwnedOwnCatalogueText: string; openOrderOwnedWrongMediumtypeText: string; - openOrderResponseIsReservedForYouText: string; openOrderResponseTitleText: string; openOrderServiceUnavailableText: string; openOrderStatusOwnedAcceptedText: string; diff --git a/src/components/material/material-buttons/MaterialButtons.tsx b/src/components/material/material-buttons/MaterialButtons.tsx index 2e4ca08645..1773040a14 100644 --- a/src/components/material/material-buttons/MaterialButtons.tsx +++ b/src/components/material/material-buttons/MaterialButtons.tsx @@ -1,7 +1,10 @@ import * as React from "react"; import { FC } from "react"; import { AccessTypeCode } from "../../../core/dbc-gateway/generated/graphql"; -import { getAllFaustIds } from "../../../core/utils/helpers/general"; +import { + getAllFaustIds, + getManifestationType +} from "../../../core/utils/helpers/general"; import { ButtonSize } from "../../../core/utils/types/button"; import { Manifestation } from "../../../core/utils/types/entities"; import { hasCorrectAccess, hasCorrectAccessType, isArticle } from "./helper"; @@ -9,6 +12,8 @@ import { WorkId } from "../../../core/utils/types/ids"; import MaterialButtonsOnline from "./online/MaterialButtonsOnline"; import MaterialButtonsFindOnShelf from "./physical/MaterialButtonsFindOnShelf"; import MaterialButtonsPhysical from "./physical/MaterialButtonsPhysical"; +import MaterialButtonReservableFromAnotherLibrary from "./physical/MaterialButtonReservableFromAnotherLibrary"; +import useReservableFromAnotherLibrary from "../../../core/utils/useReservableFromAnotherLibrary"; export interface MaterialButtonsProps { manifestations: Manifestation[]; @@ -29,6 +34,20 @@ const MaterialButtons: FC = ({ // We don't want to show physical buttons/find on shelf for articles because // articles appear as a part of journal/periodical publications and can't be // physically loaned for themseleves. + + const reservablePidsFromAnotherLibrary = + useReservableFromAnotherLibrary(manifestations); + + if (reservablePidsFromAnotherLibrary.length > 0) { + return ( + + ); + } + return ( <> {hasCorrectAccessType(AccessTypeCode.Physical, manifestations) && diff --git a/src/components/material/material-buttons/physical/MaterialButtonsPhysical.tsx b/src/components/material/material-buttons/physical/MaterialButtonsPhysical.tsx index f673ca2f6b..bdfec68f0f 100644 --- a/src/components/material/material-buttons/physical/MaterialButtonsPhysical.tsx +++ b/src/components/material/material-buttons/physical/MaterialButtonsPhysical.tsx @@ -1,8 +1,7 @@ import React from "react"; import { getAllFaustIds, - getManifestationType, - getReservablePidsFromAnotherLibrary + getManifestationType } from "../../../../core/utils/helpers/general"; import { isBlocked, usePatronData } from "../../../../core/utils/helpers/user"; import { ButtonSize } from "../../../../core/utils/types/button"; @@ -13,7 +12,6 @@ import MaterialButtonReservePhysical from "./MaterialButtonPhysical"; import MaterialButtonLoading from "../generic/MaterialButtonLoading"; import MaterialButtonDisabled from "../generic/MaterialButtonDisabled"; import { useText } from "../../../../core/utils/text"; -import MaterialButtonReservableFromAnotherLibrary from "./MaterialButtonReservableFromAnotherLibrary"; export interface MaterialButtonsPhysicalProps { manifestations: Manifestation[]; @@ -26,8 +24,6 @@ const MaterialButtonsPhysical: React.FC = ({ size, dataCy = "material-buttons-physical" }) => { - const isReservableFromAnotherLibrary = - getReservablePidsFromAnotherLibrary(manifestations); const t = useText(); const faustIds = getAllFaustIds(manifestations); const { reservableManifestations } = UseReservableManifestations({ @@ -48,16 +44,6 @@ const MaterialButtonsPhysical: React.FC = ({ return ; } - if (isReservableFromAnotherLibrary.length > 0) { - return ( - - ); - } - // We show the reservation button if the user isn't logged in or isn't blocked. // In the former case there there's no way to see if they're blocked, so we // redirect anonymous user to the login page. diff --git a/src/components/reservation/ReservationModalBody.tsx b/src/components/reservation/ReservationModalBody.tsx index 5e5ae83b04..e725869010 100644 --- a/src/components/reservation/ReservationModalBody.tsx +++ b/src/components/reservation/ReservationModalBody.tsx @@ -6,8 +6,7 @@ import { getAllPids, getMaterialTypes, getManifestationType, - materialIsFiction, - getReservablePidsFromAnotherLibrary + materialIsFiction } from "../../core/utils/helpers/general"; import { useText } from "../../core/utils/text"; import { Button } from "../Buttons/Button"; @@ -63,6 +62,7 @@ import { import ModalMessage from "../message/modal-message/ModalMessage"; import configuration, { getConf } from "../../core/configuration"; import { usePatronData } from "../../core/utils/helpers/user"; +import useReservableFromAnotherLibrary from "../../core/utils/useReservableFromAnotherLibrary"; type ReservationModalProps = { selectedManifestations: Manifestation[]; @@ -123,15 +123,19 @@ export const ReservationModalBody = ({ work, allPids ); + const manifestationsToReserve = getManifestationsToReserve( + reservableManifestations ?? [], + !!selectedPeriodical + ); + + const reservablePidsFromAnotherLibrary = useReservableFromAnotherLibrary( + selectedManifestations + ); // If we don't have all data for displaying the view render nothing. if (!userResponse.data || !holdingsResponse.data) { return null; } - const manifestationsToReserve = getManifestationsToReserve( - reservableManifestations ?? [], - !!selectedPeriodical - ); const { data: userData } = userResponse as { data: AuthenticatedPatronV6 }; const { data: holdingsData } = holdingsResponse as { data: HoldingsForBibliographicalRecordV3[]; @@ -144,22 +148,42 @@ export const ReservationModalBody = ({ ? getFutureDateString(selectedInterest) : null; - const pidsFromAnotherLibrary = getReservablePidsFromAnotherLibrary( - manifestationsToReserve - ); - const saveReservation = () => { - if (!manifestationsToReserve || manifestationsToReserve.length < 1) { - return; + if (manifestationsToReserve?.length) { + // Save reservation to FBS. + mutateAddReservations( + { + data: constructReservationData({ + manifestations: manifestationsToReserve, + selectedBranch, + expiryDate, + periodical: selectedPeriodical + }) + }, + { + onSuccess: (res) => { + // Track only if the reservation has been successfully saved. + track("click", { + id: statistics.reservation.id, + name: statistics.reservation.name, + trackedData: work.workId + }); + // This state is used to show the success or error modal. + setReservationResponse(res); + // Because after a successful reservation the holdings (reservations) are updated. + queryClient.invalidateQueries(getGetHoldingsV3QueryKey()); + } + } + ); } - if (pidsFromAnotherLibrary.length > 0 && patron) { + if (reservablePidsFromAnotherLibrary?.length && patron) { const { patronId, name, emailAddress, preferredPickupBranch } = patron; - + // Save reservation to open order. mutateOpenOrder( { input: { - pids: [...pidsFromAnotherLibrary], + pids: [...reservablePidsFromAnotherLibrary], pickUpBranch: selectedBranch ? removePrefixFromBranchId(selectedBranch) : removePrefixFromBranchId(preferredPickupBranch), @@ -179,35 +203,7 @@ export const ReservationModalBody = ({ } } ); - - return; } - - // Save reservation to FBS. - mutateAddReservations( - { - data: constructReservationData({ - manifestations: manifestationsToReserve, - selectedBranch, - expiryDate, - periodical: selectedPeriodical - }) - }, - { - onSuccess: (res) => { - // Track only if the reservation has been successfully saved. - track("click", { - id: statistics.reservation.id, - name: statistics.reservation.name, - trackedData: work.workId - }); - // This state is used to show the success or error modal. - setReservationResponse(res); - // Because after a successful reservation the holdings (reservations) are updated. - queryClient.invalidateQueries(getGetHoldingsV3QueryKey()); - } - } - ); }; const reservationSuccess = reservationResponse?.success || false; @@ -320,9 +316,7 @@ export const ReservationModalBody = ({ {openOrderResponse?.submitOrder?.status && ( { return creators[0]; }; -export const getReservablePidsFromAnotherLibrary = ( - manifestations: Manifestation[] -) => { - const matchingManifestations = manifestations.filter(({ catalogueCodes }) => - catalogueCodes?.otherCatalogues.some((code) => code.startsWith("OVE")) - ); - - return matchingManifestations.map(({ pid }) => pid); -}; - export default {}; /* ********************************* Vitest Section ********************************* */ if (import.meta.vitest) { const { describe, expect, it } = import.meta.vitest; - describe("getReservablePidsFromAnotherLibrary", () => { - it("should return true for isReservable and correct pids if manifestations are reservable on another library (catalogueCodes starts with 'OVE')", () => { - const result = getReservablePidsFromAnotherLibrary([ - { - pid: "870970-basis:135721719", - catalogueCodes: { - otherCatalogues: ["OVE123"], - nationalBibliography: ["ABE123"] - } - }, - { - pid: "870970-basis:135721719", - catalogueCodes: { - otherCatalogues: ["OVE124"], - nationalBibliography: ["ABE456"] - } - } - ] as unknown as Manifestation[]); - - expect(result.length > 0).toBe(true); - expect(result).toEqual([ - "870970-basis:135721719", - "870970-basis:135721719" - ]); - }); - - it("should return false for isReservable and empty pids array if no manifestations are reservable on another library", () => { - const result = getReservablePidsFromAnotherLibrary([ - { - pid: "pid1", - catalogueCodes: { - otherCatalogues: ["ABE789"], - nationalBibliography: ["ABE123"] - } - } - ] as unknown as Manifestation[]); - - expect(result.length > 0).toBe(false); - expect(result).toEqual([]); - }); - - it("should filter out non-reservable manifestations and return only reservable pids", () => { - const result = getReservablePidsFromAnotherLibrary([ - { - pid: "870970-basis:135721719", - catalogueCodes: { - otherCatalogues: ["OVE123"], - nationalBibliography: ["ABE123"] - } - }, - { - pid: "870970-basis:111111111", - catalogueCodes: { - otherCatalogues: ["ABE789"], - nationalBibliography: ["ABE456"] - } - } - ] as unknown as Manifestation[]); - - expect(result.length > 0).toBe(true); - expect(result).toEqual(["870970-basis:135721719"]); - }); - }); - describe("getMaterialTypes", () => { const manifestations = [ { diff --git a/src/core/utils/useReservableFromAnotherLibrary.tsx b/src/core/utils/useReservableFromAnotherLibrary.tsx new file mode 100644 index 0000000000..a418eadbb3 --- /dev/null +++ b/src/core/utils/useReservableFromAnotherLibrary.tsx @@ -0,0 +1,29 @@ +import { useConfig } from "./config"; +import { getAllFaustIds } from "./helpers/general"; +import { useGetHoldings } from "../../apps/material/helper"; +import { Manifestation } from "./types/entities"; +import { Pid } from "./types/ids"; + +const useReservableFromAnotherLibrary = ( + manifestations: Manifestation[] +): Pid[] => { + const config = useConfig(); + const { data: holdingsData } = useGetHoldings({ + faustIds: getAllFaustIds(manifestations), + config + }); + + // If there is no holdings data or if there are holdings that are reservable, we return an empty array. + // Because we use the array length to determine if we should show the button or not. + if (holdingsData?.some(({ reservable }) => reservable === true)) { + return []; + } + + return manifestations + .filter(({ catalogueCodes }) => + catalogueCodes?.otherCatalogues.some((code) => code.startsWith("OVE")) + ) + .map(({ pid }) => pid); +}; + +export default useReservableFromAnotherLibrary; diff --git a/src/tests/unit/useReservableFromAnotherLibrary.test.tsx b/src/tests/unit/useReservableFromAnotherLibrary.test.tsx new file mode 100644 index 0000000000..d3bbe9036f --- /dev/null +++ b/src/tests/unit/useReservableFromAnotherLibrary.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeAll } from "vitest"; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import { Provider } from "react-redux"; +import React, { ReactElement } from "react"; +import { renderHook } from "@testing-library/react-hooks"; +import { act } from "react-dom/test-utils"; +import { QueryClient, QueryClientProvider } from "react-query"; +import useReservableFromAnotherLibrary from "../../core/utils/useReservableFromAnotherLibrary"; +import { Manifestation } from "../../core/utils/types/entities"; +import configReducer from "../../core/config.slice"; +import { useGetHoldings } from "../../apps/material/helper"; + +const queryClient = new QueryClient(); + +const store = configureStore({ + reducer: combineReducers({ + config: configReducer + }), + preloadedState: { + config: { + data: { + blacklistedPickupBranchesConfig: "" + } + } + } +}); + +const mockedManifestations = [ + { + pid: "870970-basis:27721257", + catalogueCodes: { + otherCatalogues: ["OVE123"] + } + } +] as unknown as Manifestation[]; + +const Wrapper = ({ children }: { children: ReactElement }) => ( + + {children} + +); + +describe("useReservableFromAnotherLibrary", () => { + beforeAll(() => { + vi.mock("../../apps/material/helper", () => ({ + useGetHoldings: vi.fn() + })); + }); + + it("should return reservable pids from another library", async () => { + // Typescript does not understand our mocked hook. + // So we gracefully ignore the error :). + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-next-line + useGetHoldings.mockReturnValue({ + data: [ + { + recordId: "52643414", + reservable: false, // This is the key to the test + reservations: 0, + holdings: [] + } + ], + isLoading: false, + isError: false + }); + + const { result } = renderHook( + () => useReservableFromAnotherLibrary(mockedManifestations), + { + wrapper: Wrapper + } + ); + + act(() => { + expect(result.current).toEqual(["870970-basis:27721257"]); + }); + }); + + it("should return an empty array if useGetHoldings does return reservable items", async () => { + // Typescript does not understand our mocked hook. + // So we gracefully ignore the error :). + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore-next-line + useGetHoldings.mockReturnValue({ + data: [ + { + recordId: "52643414", + reservable: true, // This is the key to the test + reservations: 0, + holdings: [] + } + ], + isLoading: false, + isError: false + }); + + const { result } = renderHook( + () => useReservableFromAnotherLibrary(mockedManifestations), + { + wrapper: Wrapper + } + ); + + act(() => { + expect(result.current).toEqual([]); + }); + }); +});