diff --git a/CHANGELOG.md b/CHANGELOG.md index 3409d9a3..8d27b5ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ * ECS - expand `ConsortiumFieldInventory` component with `additionalAffiliationIds` prop to display affiliation name for User without affiliation in specific tenant. Refs UISACQCOMP-220. * Change `FundFilter` component to support multi-selection for Fund codes. Refs UISACQCOMP-221. * Use `actionDate` value for version history card title instead of `eventDate`. Refs UISACQCOMP-222. +* ECS - Add reusable custom hooks to fix invalid reference issues related to holding names and locations. Refs UISACQCOMP-223. ## [5.1.2](https://github.com/folio-org/stripes-acq-components/tree/v5.1.2) (2024-09-13) [Full Changelog](https://github.com/folio-org/stripes-acq-components/compare/v5.1.1...v5.1.2) diff --git a/lib/hooks/consortia/useConsortiumLocations/useConsortiumLocations.js b/lib/hooks/consortia/useConsortiumLocations/useConsortiumLocations.js index 029a4665..b98acc0a 100644 --- a/lib/hooks/consortia/useConsortiumLocations/useConsortiumLocations.js +++ b/lib/hooks/consortia/useConsortiumLocations/useConsortiumLocations.js @@ -15,7 +15,7 @@ export const useConsortiumLocations = (options = {}) => { const stripes = useStripes(); const centralTenantId = getConsortiumCentralTenantId(stripes); const ky = useOkapiKy({ tenant: centralTenantId }); - const [namespace] = useNamespace({ key: 'locations' }); + const [namespace] = useNamespace({ key: 'consortium-locations' }); const { enabled = true, diff --git a/lib/hooks/index.js b/lib/hooks/index.js index 801f9e82..46e1b71e 100644 --- a/lib/hooks/index.js +++ b/lib/hooks/index.js @@ -29,8 +29,10 @@ export * from './useModalToggle'; export * from './useOrderLine'; export * from './useOrganization'; export * from './usePaneFocus'; +export * from './useReceivingTenantIdsAndLocations'; export * from './useShowCallout'; export * from './useTags'; +export * from './useTenantHoldingsAndLocations'; export * from './useToggle'; export * from './useTranslatedCategories'; export * from './useUser'; diff --git a/lib/hooks/useReceivingTenantIdsAndLocations/index.js b/lib/hooks/useReceivingTenantIdsAndLocations/index.js new file mode 100644 index 00000000..41ba8fd3 --- /dev/null +++ b/lib/hooks/useReceivingTenantIdsAndLocations/index.js @@ -0,0 +1 @@ +export * from './useReceivingTenantIdsAndLocations'; diff --git a/lib/hooks/useReceivingTenantIdsAndLocations/useReceivingTenantIdsAndLocations.js b/lib/hooks/useReceivingTenantIdsAndLocations/useReceivingTenantIdsAndLocations.js new file mode 100644 index 00000000..40306ac6 --- /dev/null +++ b/lib/hooks/useReceivingTenantIdsAndLocations/useReceivingTenantIdsAndLocations.js @@ -0,0 +1,47 @@ +import uniq from 'lodash/uniq'; +import { useMemo } from 'react'; + +import { useCurrentUserTenants } from '../consortia'; + +/* + The purpose of this hook is to generate the list of unique tenantIds and locationIds + for the receiving tenant and locations when we need to fetch locations from other tenants via `useTenantHoldingsAndLocations` +*/ +export const useReceivingTenantIdsAndLocations = ({ + currentReceivingTenantId, + currentLocationId: locationId, + receivingTenantIds = [], +}) => { + const currentUserTenants = useCurrentUserTenants(); + + const receivingTenants = useMemo(() => { + if (receivingTenantIds.length) { + const currentUserTenantIds = currentUserTenants?.map(({ id: tenantId }) => tenantId); + + // should get unique tenantIds from poLine locations and filter out tenantIds where the current user has assigned + return uniq([ + ...receivingTenantIds, + currentReceivingTenantId, + ].filter((tenantId) => currentUserTenantIds?.includes(tenantId)) + .filter(Boolean)); + } + + return []; + }, [receivingTenantIds, currentUserTenants, currentReceivingTenantId]); + + const additionalLocations = useMemo(() => { + const locationIds = locationId ? [locationId] : []; + const tenantLocationIdsMap = currentReceivingTenantId ? { [currentReceivingTenantId]: locationIds } : {}; + + return { + additionalLocationIds: locationIds, + additionalTenantLocationIdsMap: tenantLocationIdsMap, + }; + }, [locationId, currentReceivingTenantId]); + + return { + receivingTenantIds: receivingTenants, + tenantId: currentReceivingTenantId, + ...additionalLocations, + }; +}; diff --git a/lib/hooks/useReceivingTenantIdsAndLocations/useReceivingTenantIdsAndLocations.test.js b/lib/hooks/useReceivingTenantIdsAndLocations/useReceivingTenantIdsAndLocations.test.js new file mode 100644 index 00000000..d74a6ec7 --- /dev/null +++ b/lib/hooks/useReceivingTenantIdsAndLocations/useReceivingTenantIdsAndLocations.test.js @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useReceivingTenantIdsAndLocations } from './useReceivingTenantIdsAndLocations'; + +jest.mock('../consortia', () => ({ + useCurrentUserTenants: jest.fn(() => [{ id: 'central' }, { id: 'college' }]), +})); + +describe('useReceivingTenantIdsAndLocations', () => { + it('should return receivingTenantIds', () => { + const tenants = ['central', 'college']; + const { result } = renderHook(() => useReceivingTenantIdsAndLocations({ + receivingTenantIds: tenants, + currentReceivingTenantId: 'central', + })); + + expect(result.current.receivingTenantIds).toEqual(tenants); + }); + + it('should return tenantId', () => { + const currentReceivingTenantId = 'central'; + + const { result } = renderHook(() => useReceivingTenantIdsAndLocations({ currentReceivingTenantId })); + + expect(result.current.tenantId).toBe(currentReceivingTenantId); + }); + + it('should return additionalLocationIds', () => { + const { result } = renderHook(() => useReceivingTenantIdsAndLocations({})); + + expect(result.current.additionalLocationIds).toEqual([]); + }); +}); diff --git a/lib/hooks/useTenantHoldingsAndLocations/index.js b/lib/hooks/useTenantHoldingsAndLocations/index.js new file mode 100644 index 00000000..c9f0a6bc --- /dev/null +++ b/lib/hooks/useTenantHoldingsAndLocations/index.js @@ -0,0 +1 @@ +export { useTenantHoldingsAndLocations } from './useTenantHoldingsAndLocations'; diff --git a/lib/hooks/useTenantHoldingsAndLocations/useTenantHoldingsAndLocations.js b/lib/hooks/useTenantHoldingsAndLocations/useTenantHoldingsAndLocations.js new file mode 100644 index 00000000..dfa8bd82 --- /dev/null +++ b/lib/hooks/useTenantHoldingsAndLocations/useTenantHoldingsAndLocations.js @@ -0,0 +1,84 @@ +import { useQuery } from 'react-query'; + +import { + useNamespace, + useOkapiKy, +} from '@folio/stripes/core'; + +import { LIMIT_MAX } from '../../constants'; +import { + getHoldingsAndLocations, + getHoldingsAndLocationsByTenants, +} from '../../utils'; + +const DEFAULT_DATA = []; + +/* + The purpose of this hook is to fetch holdings and locations for a given instanceId + and tenants when we need to fetch locations from other tenants when Central ordering is enabled. +*/ +export const useTenantHoldingsAndLocations = ({ + instanceId, + options = {}, + tenantId, + /* + `receivingTenantIds` is a unique list of tenantIds from the pieces list. + The purpose is that we need to be able to fetch locations from other + tenants so that we can display all the locations on the full-screen page + */ + receivingTenantIds = DEFAULT_DATA, + /* + ECS mode: + `additionalTenantLocationIdsMap` is a map of tenantId to locationIds for ECS mode. + The format can be: { tenantId: [locationId1, locationId2] } + The purpose is that we need to fetch newly added locations when we select a location + from "Create new holdings for location" modal so that the value is displayed in the selection + */ + additionalTenantLocationIdsMap = {}, + /* + Non-ECS mode: + `additionalLocationIds` is a list of locationIds for the case when we need to fetch additional + locations for the selected location in the form so that the value is displayed in the selection. + */ + additionalLocationIds = [], +}) => { + const { enabled = true, ...queryOptions } = options; + + const ky = useOkapiKy({ tenant: tenantId }); + const [namespace] = useNamespace({ key: 'holdings-and-location' }); + const queryKey = [ + namespace, + tenantId, + instanceId, + ...receivingTenantIds, + ...additionalLocationIds, + ...Object.values(additionalTenantLocationIdsMap), + ]; + const searchParams = { + query: `instanceId==${instanceId}`, + limit: LIMIT_MAX, + }; + + const { + data, + isFetching, + isLoading, + } = useQuery({ + queryKey, + queryFn: ({ signal }) => { + return receivingTenantIds.length + ? getHoldingsAndLocationsByTenants({ ky, instanceId, receivingTenantIds, additionalTenantLocationIdsMap }) + : getHoldingsAndLocations({ ky, searchParams, signal, additionalLocationIds }); + }, + enabled: enabled && Boolean(instanceId), + ...queryOptions, + }); + + return ({ + holdings: data?.holdings || DEFAULT_DATA, + locations: data?.locations || DEFAULT_DATA, + locationIds: data?.locationIds || DEFAULT_DATA, + isFetching, + isLoading, + }); +}; diff --git a/lib/hooks/useTenantHoldingsAndLocations/useTenantHoldingsAndLocations.test.js b/lib/hooks/useTenantHoldingsAndLocations/useTenantHoldingsAndLocations.test.js new file mode 100644 index 00000000..fd8eb5e4 --- /dev/null +++ b/lib/hooks/useTenantHoldingsAndLocations/useTenantHoldingsAndLocations.test.js @@ -0,0 +1,91 @@ +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; + +import { + renderHook, +} from '@testing-library/react-hooks'; +import { useOkapiKy } from '@folio/stripes/core'; + +import { HOLDINGS_API } from '../../constants'; +import { extendKyWithTenant } from '../../utils'; +import { useTenantHoldingsAndLocations } from './useTenantHoldingsAndLocations'; + +jest.mock('../../utils', () => ({ + ...jest.requireActual('../../utils'), + extendKyWithTenant: jest.fn().mockReturnValue({ extend: jest.fn() }), +})); + +const queryClient = new QueryClient(); +const wrapper = ({ children }) => ( + + {children} + +); + +const holdingsRecords = [ + { + 'id': 'd7e99892-6d7d-46eb-8a4c-3aa471785819', + 'instanceId': '8804aeeb-db18-4c42-b0e9-28d63c7855e6', + 'permanentLocationId': '9d1b77e8-f02e-4b7f-b296-3f2042ddac54', + }, + { + 'id': '5636949f-8f97-4b25-a0fc-90fdb050ffb0', + 'instanceId': '8804aeeb-db18-4c42-b0e9-28d63c7855e6', + 'permanentLocationId': '9d1b77e8-f02e-4b7f-b296-3f2042ddac54', + }, +]; + +const locations = [ + { + 'id': '9d1b77e8-f02e-4b7f-b296-3f2042ddac54', + 'name': 'DCB', + 'code': '000', + }, +]; + +const getMock = jest.fn() + .mockReturnValueOnce({ json: () => Promise.resolve({ holdingsRecords }) }) + .mockReturnValue({ json: () => Promise.resolve({ locations }) }); + +describe('useTenantHoldingsAndLocations', () => { + beforeEach(() => { + useOkapiKy + .mockClear() + .mockReturnValue({ + get: getMock, + extend: jest.fn(() => ({ + get: jest.fn((path) => { + if (path === HOLDINGS_API) { + return { + json: jest.fn().mockResolvedValue({ holdingsRecords }), + }; + } + + return ({ + json: jest.fn().mockResolvedValue({ locations }), + }); + }), + })), + }); + extendKyWithTenant.mockClear().mockReturnValue({ extend: jest.fn() }); + }); + + it('should fetch holding locations', async () => { + const { result, waitFor } = renderHook(() => useTenantHoldingsAndLocations({ instanceId: '1', tenantId: '2' }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBeFalsy()); + + expect(result.current.locations).toEqual(locations); + }); + + it('should fetch holding locations with different tenants', async () => { + const receivingTenantIds = ['1', '2']; + const { result, waitFor } = renderHook(() => useTenantHoldingsAndLocations({ instanceId: '1', receivingTenantIds, tenantId: '2' }), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBeFalsy()); + + expect(result.current.locations).toHaveLength(2); + }); +}); diff --git a/lib/utils/extendKyWithTenant.js b/lib/utils/extendKyWithTenant.js new file mode 100644 index 00000000..0c3b31b3 --- /dev/null +++ b/lib/utils/extendKyWithTenant.js @@ -0,0 +1,13 @@ +import { OKAPI_TENANT_HEADER } from '../constants'; + +export const extendKyWithTenant = (ky, tenantId) => { + return ky.extend({ + hooks: { + beforeRequest: [ + request => { + request.headers.set(OKAPI_TENANT_HEADER, tenantId); + }, + ], + }, + }); +}; diff --git a/lib/utils/getTenantHoldingsAndLocations.js b/lib/utils/getTenantHoldingsAndLocations.js new file mode 100644 index 00000000..823f85a2 --- /dev/null +++ b/lib/utils/getTenantHoldingsAndLocations.js @@ -0,0 +1,99 @@ +import { + HOLDINGS_API, + LIMIT_MAX, + LOCATIONS_API, +} from '../constants'; +import { batchRequest } from './batchFetch'; +import { extendKyWithTenant } from './extendKyWithTenant'; + +const DEFAULT_DATA = []; + +export const getHoldingsAndLocations = async ({ + ky, + searchParams, + signal, + /* + Non-ECS mode: + `additionalLocationIds` is a list of locationIds for the case when we need to fetch additional + locations for the selected location in the form so that the value is displayed in the selection. + */ + additionalLocationIds = [], + tenantId, +}) => { + const holdings = await ky + .get(HOLDINGS_API, { searchParams, signal }) + .json() + .then(response => { + return tenantId ? + response.holdingsRecords.map(holding => ({ ...holding, tenantId })) + : response.holdingsRecords; + }); + + const locationIds = holdings?.map(({ permanentLocationId }) => permanentLocationId) || DEFAULT_DATA; + const uniqueLocationIds = [...new Set([...locationIds, ...additionalLocationIds])]; + + const locations = await batchRequest( + ({ params }) => ky.get(LOCATIONS_API, { searchParams: params, signal }).json(), + uniqueLocationIds, + ).then(responses => responses.flatMap(response => { + return tenantId + ? response.locations.map(location => ({ ...location, tenantId })) + : response.locations; + })); + + return { + holdings, + locations, + locationIds, + }; +}; + +export const getHoldingsAndLocationsByTenants = async ({ + ky, + instanceId, + /* + ECS mode: + `additionalTenantLocationIdsMap` is a map of tenantId to locationIds for ECS mode. + The format can be: { tenantId: [locationId1, locationId2] } + The purpose is that we need to fetch newly added locations when we select a location + from "Create new holdings for location" modal so that the value is displayed in the selection + */ + additionalTenantLocationIdsMap = {}, + receivingTenantIds = DEFAULT_DATA, +}) => { + const searchParams = { + query: `instanceId==${instanceId}`, + limit: LIMIT_MAX, + }; + + if (!receivingTenantIds.length) { + return { + locations: DEFAULT_DATA, + locationIds: DEFAULT_DATA, + holdings: DEFAULT_DATA, + }; + } + + const locationsRequest = receivingTenantIds.map(async (tenantId) => { + const tenantKy = extendKyWithTenant(ky, tenantId); + + return getHoldingsAndLocations({ + ky: tenantKy, + searchParams, + tenantId, + additionalLocationIds: additionalTenantLocationIdsMap[tenantId], + }); + }); + + const locationsResponse = await Promise.all(locationsRequest).then(responses => { + return responses.reduce((acc, item) => { + return { + locations: acc.locations.concat(item.locations), + locationIds: acc.locationIds.concat(item.locationIds), + holdings: acc.holdings.concat(item.holdings), + }; + }, { locations: DEFAULT_DATA, locationIds: DEFAULT_DATA, holdings: DEFAULT_DATA }); + }); + + return locationsResponse; +}; diff --git a/lib/utils/getTenantHoldingsAndLocations.test.js b/lib/utils/getTenantHoldingsAndLocations.test.js new file mode 100644 index 00000000..13432e8e --- /dev/null +++ b/lib/utils/getTenantHoldingsAndLocations.test.js @@ -0,0 +1,105 @@ +import { HOLDINGS_API } from '../constants'; +import { extendKyWithTenant } from './extendKyWithTenant'; +import { + getHoldingsAndLocations, + getHoldingsAndLocationsByTenants, +} from './getTenantHoldingsAndLocations'; + +jest.mock('./extendKyWithTenant', () => ({ + extendKyWithTenant: jest.fn(), +})); + +const holdingsRecords = [{ + id: 'holding-id', + permanentLocationId: 'location-id', +}]; + +const locations = [{ + id: 'location-id', + name: 'location-name', +}]; + +describe('utils', () => { + let ky; + + beforeEach(() => { + ky = { + get: jest.fn((path) => { + if (path === HOLDINGS_API) { + return { + json: jest.fn().mockResolvedValue({ holdingsRecords }), + }; + } + + return ({ + json: jest.fn().mockResolvedValue({ locations }), + }); + }), + }; + }); + + describe('getHoldingsAndLocations', () => { + it('should return holdings, locations and locationIds', async () => { + const searchParams = {}; + const signal = { signal: 'signal' }; + + const result = await getHoldingsAndLocations({ ky, searchParams, signal }); + + expect(result).toEqual({ + holdings: holdingsRecords, + locations, + locationIds: locations.map(({ id }) => id), + }); + }); + + it('should return holdings, locations and locationIds with tenantId', async () => { + const searchParams = {}; + const signal = { signal: 'signal' }; + const tenantId = 'tenant-id'; + + const result = await getHoldingsAndLocations({ ky, searchParams, signal, tenantId }); + + expect(result).toEqual({ + holdings: holdingsRecords.map(holding => ({ ...holding, tenantId })), + locations: locations.map(location => ({ ...location, tenantId })), + locationIds: locations.map(({ id }) => id), + }); + }); + }); + + describe('getHoldingsAndLocationsByTenants', () => { + beforeEach(() => { + extendKyWithTenant.mockImplementation((tenantKy, tenantId) => { + return { ...tenantKy, tenantId }; + }); + }); + + it('should return locationsResponse', async () => { + const instanceId = 'instance-id'; + const receivingTenantIds = ['tenant-id']; + + const result = await getHoldingsAndLocationsByTenants({ ky, instanceId, receivingTenantIds }); + + expect(result).toEqual({ + holdings: holdingsRecords.map(holding => ({ ...holding, tenantId: receivingTenantIds[0] })), + locations: locations.map(location => ({ ...location, tenantId: receivingTenantIds[0] })), + locationIds: locations.map(({ id }) => id), + }); + }); + + it('should return empty array of holdings, locations and locationIds when `receivingTenantIds` and not present or empty array', async () => { + const instanceId = 'instance-id'; + + const result = await getHoldingsAndLocationsByTenants({ + ky, + instanceId, + }); + + expect(result).toEqual({ + holdings: [], + locations: [], + locationIds: [], + }); + }); + }); +}); diff --git a/lib/utils/index.js b/lib/utils/index.js index b8af9be5..efd2c9a3 100644 --- a/lib/utils/index.js +++ b/lib/utils/index.js @@ -6,6 +6,7 @@ export * from './createClearFilterHandler'; export * from './downloadBase64'; export * from './errorHandling'; export * from './EventEmitter'; +export * from './extendKyWithTenant'; export * from './fetchAllRecords'; export * from './fetchExportDataByIds'; export * from './filterArrayValues'; @@ -20,6 +21,7 @@ export * from './getConfigSetting'; export * from './getControlledVocabTranslations'; export * from './getErrorCodeFromResponse'; export * from './getFundsForSelect'; +export * from './getTenantHoldingsAndLocations'; export * from './getLocationOptions'; export * from './getMoneyMultiplier'; export * from './getOrganizationsOptions';