diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ab14ac..afe79524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ # Change history for ui-requests -## 11.0.0 IN PROGRESS +## 11.0.0 (IN PROGRESS) +* Use settings/entries endpoint to get settings information. Refs UIREQ-1062. +* Requests app.: Editing requests (ECS with mod-tlr enabled). Refs UIREQ-1088. +* Requests app.: Cancelling request (ECS with mod-tlr enabled). Refs UIREQ-1090. +* Requests app.: Reorder request queue (ECS with mod-tlr enabled). Refs UIREQ-1098. +* Requests app.: Moving request (ECS with mod-tlr enabled). Refs UIREQ-1100. +* Hide Action menu on secondary requests (ECS + mod-tlr). Refs UIREQ-1105. +* *BREAKING* Use `circulation/items-by-instance` endpoint to get item and instance information. Refs UIREQ-1091. +* Hide Duplicate and Move action buttons in ECS env with mod-tlr enabled. Refs UIREQ-1127, UIREQ-1125. +* Update permissions set to be able to get item/instance information. Refs UIREQ-1148. +* *BREAKING* Migrate to new endpoints to get request types and to create a new request. Refs UIREQ-1113. +* Use `instanceId` param for ILR from items response. Refs UIREQ-1149. +* Send `holdingsRecordId` param for Item level requests. Refs UIREQ-1167. +* Add `tlr.settings.get` permission. Refs UIREQ-1169. +* Add `mod-settings.global.read.circulation` permission. Refs UIREQ-1170. +* Add `mod-settings.entries.collection.get` permission. Refs UIREQ-1177. +* *BREAKING* Migrate to new `mod-circulation-bff` endpoints. Refs UIREQ-1134. ## [10.0.1] (https://github.com/folio-org/ui-requests/tree/v10.0.1) (2024-11-13) [Full Changelog](https://github.com/folio-org/ui-requests/compare/v10.0.0...v10.0.1) diff --git a/package.json b/package.json index fee08e1b..1bd5cbba 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "request-storage": "6.0", "pick-slips": "0.1", "search-slips": "0.1", - "automated-patron-blocks": "0.1" + "automated-patron-blocks": "0.1", + "circulation-bff-requests": "1.0" }, "permissionSets": [ { @@ -57,7 +58,7 @@ "subPermissions": [ "circulation.requests.queue.collection.get", "circulation.requests.queue.reorder.collection.post", - "circulation.requests.allowed-service-points.get", + "circulation-bff.requests.allowed-service-points.get", "circulation.rules.request-policy.get" ] }, @@ -80,7 +81,6 @@ "subPermissions": [ "module.requests.enabled", "circulation.loans.collection.get", - "circulation.settings.collection.get", "circulation.settings.item.get", "circulation.requests.collection.get", "circulation.requests.item.get", @@ -108,7 +108,11 @@ "inventory-storage.instances.item.get", "inventory-storage.instances.collection.get", "manualblocks.collection.get", - "circulation.requests.hold-shelf-clearance-report.get" + "circulation.requests.hold-shelf-clearance-report.get", + "circulation.settings.collection.get", + "tlr.settings.get", + "mod-settings.global.read.circulation", + "mod-settings.entries.collection.get" ], "visible": true }, @@ -118,15 +122,16 @@ "subPermissions": [ "ui-requests.view", "automated-patron-blocks.collection.get", - "circulation.requests.item.post", - "circulation.requests.allowed-service-points.get", + "circulation-bff.requests.allowed-service-points.get", "circulation-storage.requests.item.post", "circulation-storage.request-preferences.collection.get", "circulation-storage.staff-slips.collection.get", "circulation.pick-slips.get", "circulation.search-slips.get", "circulation.print-events-entry.item.post", - "inventory-storage.locations.item.get" + "inventory-storage.locations.item.get", + "circulation-bff.requests.search-instances.get", + "circulation-bff.requests.post" ], "visible": true }, @@ -138,7 +143,7 @@ "circulation.pick-slips.get", "circulation.search-slips.get", "circulation.requests.item.put", - "circulation.requests.allowed-service-points.get", + "circulation-bff.requests.allowed-service-points.get", "circulation.print-events-entry.item.post", "circulation-storage.staff-slips.collection.get", "circulation-storage.requests.collection.delete", diff --git a/src/ItemDetail.test.js b/src/ItemDetail.test.js index 7199b870..da740f54 100644 --- a/src/ItemDetail.test.js +++ b/src/ItemDetail.test.js @@ -9,6 +9,7 @@ import ItemDetail from './ItemDetail'; import { INVALID_REQUEST_HARDCODED_ID } from './constants'; jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), Link: jest.fn(({ to, children }) => {children}), })); diff --git a/src/ItemsDialog.js b/src/ItemsDialog.js index 77d3e3d3..1dd77754 100644 --- a/src/ItemsDialog.js +++ b/src/ItemsDialog.js @@ -86,28 +86,17 @@ const ItemsDialog = ({ const [items, setItems] = useState([]); const { formatMessage } = useIntl(); - const fetchHoldings = () => { - const query = `instanceId==${instanceId}`; - mutator.holdings.reset(); + const fetchItems = () => { + const query = `id==${instanceId}`; - return mutator.holdings.GET({ params: { query, limit: MAX_RECORDS } }); - }; - - const fetchItems = async (holdings) => { - const chunkedItems = chunk(holdings, CHUNK_SIZE); - const data = []; - - for (const itemChunk of chunkedItems) { - const query = itemChunk.map(i => `holdingsRecordId==${i.id}`).join(' or '); - - mutator.items.reset(); - // eslint-disable-next-line no-await-in-loop - const result = await mutator.items.GET({ params: { query, limit: MAX_RECORDS } }); - - data.push(...result); - } + mutator.items.reset(); - return data; + return mutator.items.GET({ + params: { + query, + limit: MAX_RECORDS, + }, + }); }; const fetchRequests = async (itemsList) => { @@ -136,8 +125,7 @@ const ItemsDialog = ({ const getItems = async () => { setAreItemsBeingLoaded(true); - const holdings = await fetchHoldings(); - let itemsList = await fetchItems(holdings); + let itemsList = await fetchItems(); if (skippedItemId) { itemsList = itemsList.filter(item => requestableItemStatuses.includes(item.status?.name)); @@ -229,17 +217,10 @@ const ItemsDialog = ({ }; ItemsDialog.manifest = { - holdings: { - type: 'okapi', - records: 'holdingsRecords', - path: 'holdings-storage/holdings', - accumulate: true, - fetch: false, - }, items: { type: 'okapi', records: 'items', - path: 'inventory/items', + path: 'circulation-bff/requests/search-instances', accumulate: true, fetch: false, }, @@ -265,10 +246,6 @@ ItemsDialog.propTypes = { skippedItemId: PropTypes.string, onRowClick: PropTypes.func, mutator: PropTypes.shape({ - holdings: PropTypes.shape({ - GET: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired, - }).isRequired, items: PropTypes.shape({ GET: PropTypes.func.isRequired, reset: PropTypes.func.isRequired, diff --git a/src/ItemsDialog.test.js b/src/ItemsDialog.test.js index 6cabf6cd..dd8be303 100644 --- a/src/ItemsDialog.test.js +++ b/src/ItemsDialog.test.js @@ -45,20 +45,6 @@ describe('ItemsDialog', () => { const testTitle = 'testTitle'; const testInstanceId = 'testInstanceId'; const testMutator = { - holdings: { - GET: jest.fn(() => (new Promise((resolve) => { - setTimeout(() => { - resolve( - [{ - id: '1', - }, { - id: '2', - }] - ); - }); - }))), - reset: jest.fn(), - }, items: { GET: jest.fn(() => (new Promise((resolve) => { setTimeout(() => { @@ -120,8 +106,6 @@ describe('ItemsDialog', () => { Paneset.mockClear(); Loading.mockClear(); onClose.mockClear(); - testMutator.holdings.GET.mockClear(); - testMutator.holdings.reset.mockClear(); testMutator.items.GET.mockClear(); testMutator.items.reset.mockClear(); testMutator.requests.GET.mockClear(); @@ -272,7 +256,6 @@ describe('ItemsDialog', () => { }); const allItemStatuses = Object.values(itemStatuses); const newMutator = { - ...testMutator, items: { GET: jest.fn(() => (new Promise((resolve) => { setTimeout(() => { diff --git a/src/MoveRequestManager.js b/src/MoveRequestManager.js index 027b5add..c73c0db6 100644 --- a/src/MoveRequestManager.js +++ b/src/MoveRequestManager.js @@ -156,7 +156,7 @@ class MoveRequestManager extends React.Component { token: stripes.store.getState().okapi.token, }) }; - const url = `${stripes.okapi.url}/circulation/requests/allowed-service-points?requestId=${request.id}&itemId=${selectedItem.id}&operation=${REQUEST_OPERATIONS.MOVE}`; + const url = `${stripes.okapi.url}/circulation-bff/requests/allowed-service-points?requestId=${request.id}&itemId=${selectedItem.id}&operation=${REQUEST_OPERATIONS.MOVE}`; this.setState({ isRequestTypesLoading: true, diff --git a/src/MoveRequestManager.test.js b/src/MoveRequestManager.test.js index 0629d5ff..7aa4518a 100644 --- a/src/MoveRequestManager.test.js +++ b/src/MoveRequestManager.test.js @@ -188,7 +188,7 @@ describe('MoveRequestManager', () => { }); it('should trigger fetch with correct argument', () => { - const expectedUrl = `${basicProps.stripes.okapi.url}/circulation/requests/allowed-service-points?requestId=${basicProps.request.id}&itemId=${selectedItem.id}&operation=${REQUEST_OPERATIONS.MOVE}`; + const expectedUrl = `${basicProps.stripes.okapi.url}/circulation-bff/requests/allowed-service-points?requestId=${basicProps.request.id}&itemId=${selectedItem.id}&operation=${REQUEST_OPERATIONS.MOVE}`; expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {}); }); diff --git a/src/PositionLink.test.js b/src/PositionLink.test.js index 8ab209d4..41e97d4e 100644 --- a/src/PositionLink.test.js +++ b/src/PositionLink.test.js @@ -10,6 +10,7 @@ import { import PositionLink from './PositionLink'; jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), Link: jest.fn(({ to, children }) => {children}), })); diff --git a/src/RequestForm.js b/src/RequestForm.js index a4e9d8d2..aacc5e43 100644 --- a/src/RequestForm.js +++ b/src/RequestForm.js @@ -18,7 +18,6 @@ import { defer, pick, isBoolean, - isNil, } from 'lodash'; import { @@ -144,7 +143,6 @@ class RequestForm extends React.Component { values: PropTypes.object.isRequired, form: PropTypes.object.isRequired, blocked: PropTypes.bool.isRequired, - instanceId: PropTypes.string.isRequired, isPatronBlocksOverridden: PropTypes.bool.isRequired, onSubmit: PropTypes.func, parentMutator: PropTypes.shape({ @@ -161,7 +159,6 @@ class RequestForm extends React.Component { onSetSelectedInstance: PropTypes.func.isRequired, onSetBlocked: PropTypes.func.isRequired, onSetIsPatronBlocksOverridden: PropTypes.func.isRequired, - onSetInstanceId: PropTypes.func.isRequired, }; static defaultProps = { @@ -718,22 +715,21 @@ class RequestForm extends React.Component { findItemRelatedResources(item) { const { findResource, - onSetInstanceId, } = this.props; - if (!item) return null; + + if (!item) { + return null; + } return Promise.all( [ findResource('loan', item.id), findResource('requestsForItem', item.id), - findResource(RESOURCE_TYPES.HOLDING, item.holdingsRecordId), ], ).then((results) => { const selectedLoan = results[0]?.loans?.[0]; const itemRequestCount = results[1]?.requests?.length; - const holdingsRecord = results[2]?.holdingsRecords?.[0]; - onSetInstanceId(holdingsRecord?.instanceId); this.setState({ itemRequestCount, selectedLoan, @@ -773,7 +769,7 @@ class RequestForm extends React.Component { if (isValidation) { return findResource(RESOURCE_TYPES.ITEM, value, key) .then((result) => { - return result.totalRecords; + return result?.items?.length; }) .finally(() => { this.setState({ isItemOrInstanceLoading: false }); @@ -788,7 +784,7 @@ class RequestForm extends React.Component { .then((result) => { this.setItemIdRequest(key, isBarcodeRequired); - if (!result || result.totalRecords === 0) { + if (!result || result?.items?.length === 0) { this.setState({ isItemOrInstanceLoading: false, }); @@ -796,21 +792,26 @@ class RequestForm extends React.Component { return null; } - const item = result.items[0]; + const foundItem = result.items?.find(item => item[key] === value); - form.change(REQUEST_FORM_FIELD_NAMES.ITEM_ID, item.id); - form.change(REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, item.barcode); + form.change(REQUEST_FORM_FIELD_NAMES.ITEM_ID, foundItem.id); + form.change(REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, foundItem.barcode); resetFieldState(form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE); // Setting state here is redundant with what follows, but it lets us // display the matched item as quickly as possible, without waiting for // the slow loan and request lookups - onSetSelectedItem(item); + onSetSelectedItem(foundItem); this.setState({ isItemOrInstanceLoading: false, }); - return item; + return foundItem; + }) + .catch(() => { + onSetSelectedItem(null); + + return null; }) .then(item => { if (item && selectedUser?.id) { @@ -825,7 +826,7 @@ class RequestForm extends React.Component { } findInstanceRelatedResources(instance) { - if (!instance) { + if (!instance?.id) { return null; } @@ -841,7 +842,7 @@ class RequestForm extends React.Component { }); } - findInstance = async (instanceId, holdingsRecordId, isValidation = false) => { + findInstance = async (instanceId, isValidation = false) => { const { findResource, form, @@ -854,14 +855,10 @@ class RequestForm extends React.Component { isItemOrInstanceLoading: true, }); - const resultInstanceId = isNil(instanceId) - ? await findResource(RESOURCE_TYPES.HOLDING, holdingsRecordId).then((holding) => holding.holdingsRecords[0].instanceId) - : instanceId; - if (isValidation) { - return findResource(RESOURCE_TYPES.INSTANCE, resultInstanceId) + return findResource(RESOURCE_TYPES.INSTANCE, instanceId) .then((result) => { - return result.totalRecords; + return Boolean(result?.id); }) .finally(() => { this.setState({ isItemOrInstanceLoading: false }); @@ -872,9 +869,9 @@ class RequestForm extends React.Component { isRequestTypesReceived: false, }); - return findResource(RESOURCE_TYPES.INSTANCE, resultInstanceId) - .then((result) => { - if (!result || result.totalRecords === 0) { + return findResource(RESOURCE_TYPES.INSTANCE, instanceId) + .then((instance) => { + if (!instance?.id) { this.setState({ isItemOrInstanceLoading: false, }); @@ -882,8 +879,6 @@ class RequestForm extends React.Component { return null; } - const instance = result.instances[0]; - form.change(REQUEST_FORM_FIELD_NAMES.INSTANCE_ID, instance.id); form.change(REQUEST_FORM_FIELD_NAMES.INSTANCE_HRID, instance.hrid); resetFieldState(form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE); @@ -895,6 +890,11 @@ class RequestForm extends React.Component { return instance; }) + .catch(() => { + onSetSelectedInstance(null); + + return null; + }) .then(instance => { if (instance && selectedUser?.id) { const requester = getRequester(proxy, selectedUser); @@ -1009,15 +1009,16 @@ class RequestForm extends React.Component { form.change(REQUEST_FORM_FIELD_NAMES.INSTANCE_ID, null); if (isCreateTlr) { - onSetSelectedItem(undefined); this.setState({ requestTypes: {}, isRequestTypesReceived: false, }); if (selectedItem) { - this.findInstance(null, selectedItem.holdingsRecordId); + this.findInstance(selectedItem.instanceId); } + + onSetSelectedItem(undefined); } else if (selectedInstance) { form.change(REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE, DEFAULT_REQUEST_TYPE_VALUE); resetFieldState(form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE); @@ -1099,7 +1100,6 @@ class RequestForm extends React.Component { selectedUser, selectedInstance, isPatronBlocksOverridden, - instanceId, blocked, values, onCancel, @@ -1289,7 +1289,6 @@ class RequestForm extends React.Component { onSetSelectedInstance={onSetSelectedInstance} isLoading={isItemOrInstanceLoading} instanceRequestCount={instanceRequestCount} - instanceId={instanceId} /> @@ -1311,7 +1310,6 @@ class RequestForm extends React.Component { onSetSelectedItem={onSetSelectedItem} values={values} itemRequestCount={itemRequestCount} - instanceId={instanceId} selectedLoan={selectedLoan} isLoading={isItemOrInstanceLoading} /> diff --git a/src/RequestForm.test.js b/src/RequestForm.test.js index 2a723aba..ace72ad8 100644 --- a/src/RequestForm.test.js +++ b/src/RequestForm.test.js @@ -19,6 +19,8 @@ import RequestForm, { } from './RequestForm'; import FulfilmentPreference from './components/FulfilmentPreference'; import RequesterInformation from './components/RequesterInformation'; +import ItemInformation from './components/ItemInformation'; +import InstanceInformation from './components/InstanceInformation'; import { REQUEST_LEVEL_TYPES, @@ -52,6 +54,8 @@ const testIds = { closePatronModalButton: 'closePatronModalButton', itemDialogCloseButton: 'itemDialogCloseButton', itemDialogRow: 'itemDialogRow', + itemEnterButton: 'itemEnterButton', + instanceEnterButton: 'instanceEnterButton', }; const fieldValue = 'value'; const idResourceType = 'id'; @@ -214,7 +218,6 @@ describe('RequestForm', () => { onSetSelectedInstance: jest.fn(), onSetSelectedItem: jest.fn(), onSetSelectedUser: jest.fn(), - onSetInstanceId: jest.fn(), onSetIsPatronBlocksOverridden: jest.fn(), onSetBlocked: jest.fn(), onShowErrorModal: jest.fn(), @@ -311,22 +314,14 @@ describe('RequestForm', () => { }); describe('Initial render', () => { - const holding = { - holdingsRecords: [ - { - instanceId: 'instanceId', - } - ], - }; const selectedItem = { + instanceId: 'instanceId', holdingsRecordId: 'holdingsRecordId', }; let findResource; beforeEach(() => { - findResource = jest.fn() - .mockResolvedValueOnce(holding) - .mockResolvedValueOnce(null); + findResource = jest.fn().mockResolvedValueOnce(null); const props = { ...basicProps, @@ -366,8 +361,8 @@ describe('RequestForm', () => { expect(basicProps.onSetSelectedItem).toHaveBeenCalledWith(undefined); }); - it('should get instance id if it is not provided', () => { - const expectedArgs = [RESOURCE_TYPES.HOLDING, selectedItem.holdingsRecordId]; + it('should get instance information', () => { + const expectedArgs = [RESOURCE_TYPES.INSTANCE, selectedItem.instanceId]; const tlrCheckbox = screen.getByTestId(testIds.tlrCheckbox); fireEvent.click(tlrCheckbox); @@ -1235,12 +1230,12 @@ describe('RequestForm', () => { }, }; const itemResult = { - totalRecords: 1, items: [ { id: 'itemId', barcode: initialItemBarcode, holdingsRecordId: 'holdingsRecordId', + instanceId, } ], }; @@ -1262,13 +1257,6 @@ describe('RequestForm', () => { const itemRequestsResult = { requests: [], }; - const holdingsRecordResult = { - holdingsRecords: [ - { - instanceId, - } - ], - }; let findResource; beforeEach(() => { @@ -1278,7 +1266,6 @@ describe('RequestForm', () => { .mockResolvedValueOnce(requestTypesResult) .mockResolvedValueOnce(loanResult) .mockResolvedValueOnce(itemRequestsResult) - .mockResolvedValueOnce(holdingsRecordResult) .mockResolvedValue({}); const props = { @@ -1347,19 +1334,6 @@ describe('RequestForm', () => { expect(findResource).toHaveBeenCalledWith(...expectedArgs); }); - it('should get information about holdings', () => { - const expectedArgs = [ - RESOURCE_TYPES.HOLDING, - itemResult.items[0].holdingsRecordId - ]; - - expect(findResource).toHaveBeenCalledWith(...expectedArgs); - }); - - it('should set instance id', () => { - expect(basicProps.onSetInstanceId).toHaveBeenCalledWith(holdingsRecordResult.holdingsRecords[0].instanceId); - }); - it('should handle item barcode field change', () => { const expectedArgs = [RESOURCE_TYPES.ITEM, fieldValue, RESOURCE_KEYS.id]; const itemField = screen.getByTestId(testIds.itemField); @@ -1381,7 +1355,6 @@ describe('RequestForm', () => { describe('When item is not found', () => { const itemResult = { - totalRecords: 0, items: [], }; let findResource; @@ -1430,6 +1403,47 @@ describe('RequestForm', () => { expect(findResource).not.toHaveBeenCalledWith(...expectedArgs); }); }); + + describe('When error during item finding', () => { + beforeEach(() => { + const props = { + ...basicProps, + request: undefined, + findResource: jest.fn() + .mockRejectedValue({}), + }; + + ItemInformation.mockImplementationOnce(({ + findItem, + }) => { + const handleClick = () => { + findItem(idResourceType, 'id', false); + }; + + return ( + + ); + }); + + renderComponent(props); + }); + + it('should reset item information', async () => { + const itemField = screen.getByTestId(testIds.itemEnterButton); + + fireEvent.click(itemField); + + await waitFor(() => { + expect(basicProps.onSetSelectedItem).toHaveBeenCalledWith(null); + }); + }); + }); }); describe('Component updating', () => { @@ -1479,7 +1493,6 @@ describe('RequestForm', () => { const initialItemId = 'itemId'; const updatedItemId = 'updatedItemId'; const itemResult = { - totalRecords: 0, items: [], }; @@ -1564,13 +1577,8 @@ describe('RequestForm', () => { }, }; const instanceResult = { - totalRecords: 1, - instances: [ - { - id: initialInstanceId, - hrid: 'hrid', - } - ], + id: initialInstanceId, + hrid: 'hrid', }; const requestTypesResult = { 'Page': [ @@ -1623,8 +1631,8 @@ describe('RequestForm', () => { it('should set instance information', () => { const expectedArgs = [ - [REQUEST_FORM_FIELD_NAMES.INSTANCE_ID, instanceResult.instances[0].id], - [REQUEST_FORM_FIELD_NAMES.INSTANCE_HRID, instanceResult.instances[0].hrid] + [REQUEST_FORM_FIELD_NAMES.INSTANCE_ID, instanceResult.id], + [REQUEST_FORM_FIELD_NAMES.INSTANCE_HRID, instanceResult.hrid] ]; expectedArgs.forEach(args => { @@ -1645,14 +1653,14 @@ describe('RequestForm', () => { it('should get information about open instance requests', () => { const expectedArgs = [ 'requestsForInstance', - instanceResult.instances[0].id + instanceResult.id ]; expect(findResource).toHaveBeenCalledWith(...expectedArgs); }); it('should set selected instance', () => { - expect(basicProps.onSetSelectedInstance).toHaveBeenCalledWith(instanceResult.instances[0]); + expect(basicProps.onSetSelectedInstance).toHaveBeenCalledWith(instanceResult); }); it('should handle instance id field change', () => { @@ -1675,10 +1683,7 @@ describe('RequestForm', () => { }); describe('When instance is not found', () => { - const instanceResult = { - totalRecords: 0, - instances: [], - }; + const instanceResult = {}; let findResource; beforeEach(() => { @@ -1716,6 +1721,51 @@ describe('RequestForm', () => { expect(resetFieldState).not.toHaveBeenCalledWith(...expectedArgs); }); }); + + describe('When error during instance finding', () => { + beforeEach(() => { + const props = { + ...basicProps, + request: undefined, + values: { + ...basicProps.values, + createTitleLevelRequest: true, + }, + findResource: jest.fn() + .mockRejectedValue({}), + }; + + InstanceInformation.mockImplementationOnce(({ + findInstance, + }) => { + const handleClick = () => { + findInstance('id', false); + }; + + return ( + + ); + }); + + renderComponent(props); + }); + + it('should reset instance information', async () => { + const itemField = screen.getByTestId(testIds.instanceEnterButton); + + fireEvent.click(itemField); + + await waitFor(() => { + expect(basicProps.onSetSelectedInstance).toHaveBeenCalledWith(null); + }); + }); + }); }); describe('Component updating', () => { diff --git a/src/RequestFormContainer.js b/src/RequestFormContainer.js index 0c062603..c2810738 100644 --- a/src/RequestFormContainer.js +++ b/src/RequestFormContainer.js @@ -39,7 +39,6 @@ const RequestFormContainer = ({ const [selectedUser, setSelectedUser] = useState({ ...requester, id: requesterId }); const [selectedInstance, setSelectedInstance] = useState(request?.instance); const [isPatronBlocksOverridden, setIsPatronBlocksOverridden] = useState(false); - const [instanceId, setInstanceId] = useState(''); const [blocked, setBlocked] = useState(false); const setItem = (optedItem) => { @@ -62,10 +61,6 @@ const RequestFormContainer = ({ setIsPatronBlocksOverridden(value); }; - const setStateInstanceId = (id) => { - setInstanceId(id); - }; - const getPatronManualBlocks = (resources) => { return (resources?.patronBlocks?.records || []) .filter(b => b.requests === true) @@ -142,7 +137,7 @@ const RequestFormContainer = ({ }; } - requestData.instanceId = request?.instanceId || instanceId || selectedInstance?.id; + requestData.instanceId = request?.instanceId || selectedInstance?.id || selectedItem?.instanceId; requestData.requestLevel = request?.requestLevel || getRequestLevelValue(requestData.createTitleLevelRequest); if (requestData.requestLevel === REQUEST_LEVEL_TYPES.ITEM) { @@ -179,7 +174,6 @@ const RequestFormContainer = ({ selectedUser={selectedUser} selectedInstance={selectedInstance} isPatronBlocksOverridden={isPatronBlocksOverridden} - instanceId={instanceId} onGetPatronManualBlocks={getPatronManualBlocks} onGetAutomatedPatronBlocks={getAutomatedPatronBlocks} onSetBlocked={setIsBlocked} @@ -187,7 +181,6 @@ const RequestFormContainer = ({ onSetSelectedUser={setUser} onSetSelectedInstance={setInstance} onSetIsPatronBlocksOverridden={setStateIsPatronBlocksOverridden} - onSetInstanceId={setStateInstanceId} onSubmit={handleSubmit} /> ); diff --git a/src/RequestFormContainer.test.js b/src/RequestFormContainer.test.js index 64af25fb..e0c4c3f5 100644 --- a/src/RequestFormContainer.test.js +++ b/src/RequestFormContainer.test.js @@ -55,7 +55,6 @@ describe('RequestFormContainer', () => { }, selectedInstance: defaultProps.request.instance, isPatronBlocksOverridden: false, - instanceId: '', onGetPatronManualBlocks: expect.any(Function), onGetAutomatedPatronBlocks: expect.any(Function), onSetBlocked: expect.any(Function), @@ -63,7 +62,6 @@ describe('RequestFormContainer', () => { onSetSelectedUser: expect.any(Function), onSetSelectedInstance: expect.any(Function), onSetIsPatronBlocksOverridden: expect.any(Function), - onSetInstanceId: expect.any(Function), onSubmit: expect.any(Function), }; @@ -100,6 +98,10 @@ describe('RequestFormContainer', () => { }; const props = { ...defaultProps, + request: { + ...defaultProps.request, + instanceId: null, + }, itemId: 'itemId', item: { id: 'id', @@ -107,6 +109,7 @@ describe('RequestFormContainer', () => { }; const selectedItem = { holdingsRecordId: 'holdingsRecordId', + instanceId: 'instanceId', }; const selectItemLabel = 'Select Item'; @@ -144,7 +147,7 @@ describe('RequestFormContainer', () => { const expectedArg = { holdingsRecordId: selectedItem.holdingsRecordId, fulfillmentPreference: fulfillmentTypeMap.HOLD_SHELF, - instanceId: defaultProps.request.instanceId, + instanceId: selectedItem.instanceId, requestLevel: REQUEST_LEVEL_TYPES.ITEM, pickupServicePointId: submitData.pickupServicePointId, item: submitData.item, diff --git a/src/ViewRequest.js b/src/ViewRequest.js index 8d00cf7b..86324a87 100644 --- a/src/ViewRequest.js +++ b/src/ViewRequest.js @@ -66,10 +66,22 @@ import { isVirtualItem, isVirtualPatron, getRequestErrorMessage, + isMultiDataTenant, } from './utils'; import urls from './routes/urls'; const CREATE_SUCCESS = 'CREATE_SUCCESS'; +const ECS_REQUEST_PHASE = { + PRIMARY: 'Primary', + SECONDARY: 'Secondary', +}; + +export const isAnyActionButtonVisible = (visibilityConditions = []) => visibilityConditions.some(condition => condition); +export const shouldHideMoveAndDuplicate = (stripes, isEcsTlrPrimaryRequest, isEcsTlrSettingReceived, isEcsTlrSettingEnabled) => { + return isEcsTlrPrimaryRequest || + (isEcsTlrSettingReceived && isEcsTlrSettingEnabled) || + (isMultiDataTenant(stripes) && !isEcsTlrSettingReceived); +}; class ViewRequest extends React.Component { static manifest = { @@ -127,7 +139,9 @@ class ViewRequest extends React.Component { other: PropTypes.shape({ totalRecords: PropTypes.number, }), - records: PropTypes.arrayOf(PropTypes.object), + records: PropTypes.arrayOf(PropTypes.shape({ + ecsRequestPhase: PropTypes.object, + })), }), }), query: PropTypes.object, @@ -141,6 +155,8 @@ class ViewRequest extends React.Component { intl: PropTypes.object, tagsEnabled: PropTypes.bool, match: PropTypes.object, + isEcsTlrSettingReceived: PropTypes.bool.isRequired, + isEcsTlrSettingEnabled: PropTypes.bool.isRequired, }; static defaultProps = { @@ -457,6 +473,8 @@ class ViewRequest extends React.Component { stripes, patronGroups, optionLists: { cancellationReasons }, + isEcsTlrSettingEnabled, + isEcsTlrSettingReceived, } = this.props; const { isCancellingRequest, @@ -468,7 +486,8 @@ class ViewRequest extends React.Component { item, requester } = request; - + const isEcsTlrPrimaryRequest = request?.ecsRequestPhase === ECS_REQUEST_PHASE.PRIMARY; + const isEcsTlrSecondaryRequest = request?.ecsRequestPhase === ECS_REQUEST_PHASE.SECONDARY; const getPickupServicePointName = this.getPickupServicePointName(request); const requestStatus = get(request, ['status'], '-'); const isRequestClosed = requestStatus.startsWith('Closed'); @@ -501,14 +520,15 @@ class ViewRequest extends React.Component { ? : '-'; - const showActionMenu = stripes.hasPerm('ui-requests.create') - || stripes.hasPerm('ui-requests.edit') - || stripes.hasPerm('ui-requests.moveRequest.execute') - || stripes.hasPerm('ui-requests.reorderQueue.execute') || !isDCBTransaction; - const actionMenu = ({ onToggle }) => { + if (isEcsTlrSecondaryRequest) { + return null; + } + + const isMoveAndDuplicateHidden = shouldHideMoveAndDuplicate(stripes, isEcsTlrPrimaryRequest, isEcsTlrSettingReceived, isEcsTlrSettingEnabled); + if (isRequestClosed) { - if (!isRequestValid || (requestLevel === REQUEST_LEVEL_TYPES.TITLE && !titleLevelRequestsFeatureEnabled) || isDCBTransaction) { + if (!isRequestValid || (requestLevel === REQUEST_LEVEL_TYPES.TITLE && !titleLevelRequestsFeatureEnabled) || isDCBTransaction || isMoveAndDuplicateHidden) { return null; } @@ -530,11 +550,18 @@ class ViewRequest extends React.Component { ); } - return ( - <> - - { - isRequestValid && !isDCBTransaction && + const isValidNotDCBTransaction = isRequestValid && !isDCBTransaction; + const isEditButtonVisible = isValidNotDCBTransaction && stripes.hasPerm('ui-requests.edit'); + const isCancelButtonVisible = stripes.hasPerm('ui-requests.edit'); + const isDuplicateButtonVisible = isValidNotDCBTransaction && !isMoveAndDuplicateHidden && stripes.hasPerm('ui-requests.create'); + const isMoveButtonVisible = item && isRequestNotFilled && isValidNotDCBTransaction && !isMoveAndDuplicateHidden && stripes.hasPerm('ui-requests.moveRequest.execute'); + const isReorderQueueButtonVisible = isRequestOpen && isValidNotDCBTransaction && stripes.hasPerm('ui-requests.reorderQueue.execute'); + const isActionMenuVisible = isAnyActionButtonVisible([isEditButtonVisible, isCancelButtonVisible, isDuplicateButtonVisible, isMoveButtonVisible, isReorderQueueButtonVisible]); + + if (isActionMenuVisible) { + return ( + <> + {isEditButtonVisible && } - - - { - isRequestValid && !isDCBTransaction && - + {isCancelButtonVisible && + + } + {isDuplicateButtonVisible && - - } - {item && isRequestNotFilled && isRequestValid && !isDCBTransaction && - + } + {isMoveButtonVisible && - } - {isRequestOpen && isRequestValid && !isDCBTransaction && - + } + {isReorderQueueButtonVisible && - } - - ); + } + + ); + } + + return null; }; const referredRecordData = { @@ -641,7 +667,7 @@ class ViewRequest extends React.Component { paneTitle={} lastMenu={this.renderDetailMenu(request)} dismissible - {... (showActionMenu ? { actionMenu } : {})} + actionMenu={actionMenu} onClose={this.props.onClose} > { }; const mockedConfig = { records: [ - { value: '{"titleLevelRequestsFeatureEnabled":true}' }, + { + value: { + titleLevelRequestsFeatureEnabled: true, + }, + } ], }; const defaultProps = { @@ -114,6 +121,7 @@ describe('ViewRequest', () => { }, stripes: { hasPerm: jest.fn(() => true), + hasInterface: jest.fn(() => true), connect: jest.fn((component) => component), logger: { log: jest.fn(), @@ -124,6 +132,8 @@ describe('ViewRequest', () => { id: 'testId', }, }, + isEcsTlrSettingEnabled: false, + isEcsTlrSettingReceived: true, }; const defaultDCBLendingProps = { ...defaultProps, @@ -195,7 +205,9 @@ describe('ViewRequest', () => { describe('request is valid', () => { describe('TLR in enabled', () => { beforeAll(() => { - mockedConfig.records[0].value = '{"titleLevelRequestsFeatureEnabled":true}'; + mockedConfig.records[0].value = { + titleLevelRequestsFeatureEnabled: true, + }; }); it('should render "Duplicate" button', () => { @@ -205,7 +217,9 @@ describe('ViewRequest', () => { describe('TLR in disabled', () => { beforeAll(() => { - mockedConfig.records[0].value = '{"titleLevelRequestsFeatureEnabled":false}'; + mockedConfig.records[0].value = { + titleLevelRequestsFeatureEnabled: false, + }; }); it('should not render "Duplicate" button', () => { @@ -490,4 +504,40 @@ describe('ViewRequest', () => { }); }); }); + + describe('isAnyActionButtonVisible', () => { + describe('When visibility conditions are provided', () => { + it('should return true', () => { + expect(isAnyActionButtonVisible([true, false])).toBe(true); + }); + }); + + describe('When visibility conditions are not provided', () => { + it('should return false', () => { + expect(isAnyActionButtonVisible()).toBe(false); + }); + }); + }); + + describe('shouldHideMoveAndDuplicate', () => { + const stripes = { + hasInterface: () => true, + }; + + it('should return true for primary request', () => { + expect(shouldHideMoveAndDuplicate(stripes, true)).toBe(true); + }); + + it('should return true if ecs tlr enabled', () => { + expect(shouldHideMoveAndDuplicate(stripes, false, true, true)).toBe(true); + }); + + it('should return true if settings are not received', () => { + expect(shouldHideMoveAndDuplicate(stripes, false, false, true)).toBe(true); + }); + + it('should return false', () => { + expect(shouldHideMoveAndDuplicate(stripes, false, true, false)).toBe(false); + }); + }); }); diff --git a/src/components/InstanceInformation/InstanceInformation.js b/src/components/InstanceInformation/InstanceInformation.js index 018e1e22..745b0d81 100644 --- a/src/components/InstanceInformation/InstanceInformation.js +++ b/src/components/InstanceInformation/InstanceInformation.js @@ -37,7 +37,6 @@ class InstanceInformation extends Component { values: PropTypes.object.isRequired, onSetSelectedInstance: PropTypes.func.isRequired, isLoading: PropTypes.bool.isRequired, - instanceId: PropTypes.string.isRequired, request: PropTypes.object, instanceRequestCount: PropTypes.number, selectedInstance: PropTypes.object, @@ -68,7 +67,7 @@ class InstanceInformation extends Component { if (instanceId && shouldValidateId) { this.setState({ shouldValidateId: false }); - const instance = await findInstance(instanceId, null, true); + const instance = await findInstance(instanceId, true); return !instance ? @@ -159,7 +158,6 @@ class InstanceInformation extends Component { values, isLoading, instanceRequestCount, - instanceId, } = this.props; const { isInstanceClicked, @@ -247,7 +245,7 @@ class InstanceInformation extends Component { { isTitleInfoVisible && { }); describe('when instance is selected', () => { - it('should render "TitleInformation" with correct props', () => { - render( - - ); - - const expectedProps = { - instanceId: basicProps.instanceId, - titleLevelRequestsCount: basicProps.instanceRequestCount, - title: basicProps.selectedInstance.title, - contributors: basicProps.selectedInstance.contributors, - publications: basicProps.selectedInstance.publication, - editions: basicProps.selectedInstance.editions, - identifiers: basicProps.selectedInstance.identifiers, - }; - - expect(TitleInformation).toHaveBeenCalledWith(expectedProps, {}); - }); - it('should render "TitleInformation" with "request.instanceId"', () => { const instanceId = 'instanceId'; const props = { @@ -627,21 +607,13 @@ describe('InstanceInformation', () => { }); it('should render "TitleInformation" with "selectedInstance.id"', () => { - const selectedInstanceId = 'selectedInstanceId'; - const props = { - ...basicProps, - selectedInstance: { - ...basicProps.selectedInstance, - id: selectedInstanceId, - }, - }; const expectedProps = { - instanceId: selectedInstanceId, + instanceId: basicProps.selectedInstance.id, }; render( ); diff --git a/src/components/ItemInformation/ItemInformation.js b/src/components/ItemInformation/ItemInformation.js index c6bf74bf..3b1e9f1a 100644 --- a/src/components/ItemInformation/ItemInformation.js +++ b/src/components/ItemInformation/ItemInformation.js @@ -35,7 +35,6 @@ class ItemInformation extends Component { request: PropTypes.object.isRequired, onSetSelectedItem: PropTypes.func.isRequired, itemRequestCount: PropTypes.number.isRequired, - instanceId: PropTypes.string.isRequired, isLoading: PropTypes.bool.isRequired, submitting: PropTypes.bool.isRequired, isItemIdRequest: PropTypes.bool.isRequired, @@ -162,7 +161,6 @@ class ItemInformation extends Component { isLoading, selectedItem, request, - instanceId, selectedLoan, itemRequestCount, } = this.props; @@ -235,7 +233,7 @@ class ItemInformation extends Component { selectedItem && { }); describe('when item is selected', () => { + const props = { + ...basicProps, + selectedItem: { + id: 'itemId', + instanceId: 'instanceId', + }, + }; + beforeEach(() => { render( ); }); @@ -557,7 +564,7 @@ describe('ItemInformation', () => { it('should render "ItemDetail" with correct props', () => { const expectedProps = { request: basicProps.request, - currentInstanceId: basicProps.instanceId, + currentInstanceId: basicProps.selectedItem.instanceId, item: basicProps.selectedItem, loan: basicProps.selectedLoan, requestCount: basicProps.itemRequestCount, diff --git a/src/constants.js b/src/constants.js index c2ef1644..a4d7c6ae 100644 --- a/src/constants.js +++ b/src/constants.js @@ -329,8 +329,8 @@ export const RESOURCE_TYPES = { ITEM: 'item', INSTANCE: 'instance', USER: 'user', - HOLDING: 'holding', REQUEST_TYPES: 'requestTypes', + ECS_TLR_SETTINGS: 'ecsTlrSettings', }; export const ENTER_EVENT_KEY = 'Enter'; @@ -340,6 +340,11 @@ export const RESOURCE_KEYS = { barcode: 'barcode', }; +export const ITEM_QUERIES = { + [RESOURCE_KEYS.id]: 'item.id', + [RESOURCE_KEYS.barcode]: 'items.barcode', +}; + export const REQUEST_FORM_FIELD_NAMES = { CREATE_TLR: 'createTitleLevelRequest', FULFILLMENT_PREFERENCE: 'fulfillmentPreference', @@ -411,3 +416,11 @@ export const DCB_USER = { export const DCB_INSTANCE_ID = '9d1b77e4-f02e-4b7f-b296-3f2042ddac54'; export const DCB_HOLDINGS_RECORD_ID = '10cd3a5a-d36f-4c7a-bc4f-e1ae3cf820c9'; + +export const SETTINGS_SCOPES = { + CIRCULATION: 'circulation', +}; + +export const SETTINGS_KEYS = { + GENERAL_TLR: 'generalTlr', +}; diff --git a/src/routes/RequestQueueRoute.js b/src/routes/RequestQueueRoute.js index 719358ee..679af841 100644 --- a/src/routes/RequestQueueRoute.js +++ b/src/routes/RequestQueueRoute.js @@ -9,7 +9,11 @@ import { stripesConnect } from '@folio/stripes/core'; import RequestQueueView from '../views/RequestQueueView'; import urls from './urls'; -import { requestStatuses } from '../constants'; +import { + requestStatuses, + SETTINGS_SCOPES, + SETTINGS_KEYS, +} from '../constants'; import { getTlrSettings, isPageRequest, @@ -29,10 +33,10 @@ class RequestQueueRoute extends React.Component { static manifest = { configs: { type: 'okapi', - records: 'configs', - path: 'configurations/entries', + records: 'items', + path: 'settings/entries', params: { - query: '(module==SETTINGS and configName==TLR)', + query: `(scope==${SETTINGS_SCOPES.CIRCULATION} and key==${SETTINGS_KEYS.GENERAL_TLR})`, }, }, addressTypes: { diff --git a/src/routes/RequestsRoute.js b/src/routes/RequestsRoute.js index d87083db..59e42060 100644 --- a/src/routes/RequestsRoute.js +++ b/src/routes/RequestsRoute.js @@ -3,6 +3,7 @@ import { isEmpty, isArray, size, + cloneDeep, } from 'lodash'; import React from 'react'; import PropTypes from 'prop-types'; @@ -23,6 +24,7 @@ import { IfPermission, CalloutContext, TitleManager, + checkIfUserInCentralTenant, } from '@folio/stripes/core'; import { Button, @@ -64,7 +66,11 @@ import { fulfillmentTypeMap, DEFAULT_REQUEST_TYPE_VALUE, INPUT_REQUEST_SEARCH_SELECTOR, + SETTINGS_SCOPES, + SETTINGS_KEYS, + ITEM_QUERIES, PRINT_DETAILS_COLUMNS, + RESOURCE_TYPES, requestFilterTypes, } from '../constants'; import { @@ -80,6 +86,7 @@ import { getSelectedSlipDataMulti, selectedRowsNonPrintable, getNextSelectedRowsState, + isMultiDataTenant, } from '../utils'; import packageInfo from '../../package'; import CheckboxColumn from '../components/CheckboxColumn'; @@ -139,24 +146,29 @@ export const getLastPrintedDetails = (printDetails, intl) => { export const urls = { user: (value, idType) => { const query = stringify({ query: `(${idType}=="${value}")` }); + return `users?${query}`; }, item: (value, idType) => { let query; + const itemQueryParam = ITEM_QUERIES[idType]; if (isArray(value)) { - query = `(${value.map((valueItem) => `${idType}=="${valueItem}"`).join(' or ')})`; + const queryElements = value.map((valueItem) => `${itemQueryParam}=="${valueItem}"`); + + query = `(${queryElements.join(' or ')})`; } else { - query = `(${idType}=="${value}")`; + query = `(${itemQueryParam}=="${value}")`; } query = stringify({ query }); - return `inventory/items?${query}`; + + return `circulation-bff/requests/search-instances?${query}`; }, instance: (value) => { const query = stringify({ query: getInstanceQueryString(value) }); - return `inventory/instances?${query}`; + return `circulation-bff/requests/search-instances?${query}`; }, loan: (value) => { const query = stringify({ query: `(itemId=="${value}") and status.name==Open` }); @@ -186,11 +198,6 @@ export const urls = { return `request-preference-storage/request-preference?${query}`; }, - holding: (value, idType) => { - const query = stringify({ query: `(${idType}=="${value}")` }); - - return `holdings-storage/holdings?${query}`; - }, requestTypes: ({ requesterId, itemId, @@ -199,10 +206,10 @@ export const urls = { operation, }) => { if (requestId) { - return `circulation/requests/allowed-service-points?operation=${operation}&requestId=${requestId}`; + return `circulation-bff/requests/allowed-service-points?operation=${operation}&requestId=${requestId}`; } - let requestUrl = `circulation/requests/allowed-service-points?requesterId=${requesterId}&operation=${operation}`; + let requestUrl = `circulation-bff/requests/allowed-service-points?requesterId=${requesterId}&operation=${operation}`; if (itemId) { requestUrl = `${requestUrl}&itemId=${itemId}`; @@ -212,6 +219,15 @@ export const urls = { return requestUrl; }, + ecsTlrSettings: (value, idType, stripes) => { + const isUserInCentralTenant = checkIfUserInCentralTenant(stripes); + + if (isUserInCentralTenant) { + return 'tlr/settings'; + } + + return 'circulation/settings?query=name==ecsTlrFeature'; + }, }; export const getListFormatter = ( @@ -347,6 +363,12 @@ class RequestsRoute extends React.Component { staticFallback: { params: {} }, }, }, + circulationRequests: { + type: 'okapi', + path: 'circulation-bff/requests', + fetch: false, + throwErrors: false, + }, reportRecords: { type: 'okapi', path: 'circulation/requests', @@ -373,27 +395,6 @@ class RequestsRoute extends React.Component { limit: MAX_RECORDS, }, }, - itemUniquenessValidator: { - type: 'okapi', - records: 'items', - accumulate: 'true', - path: 'inventory/items', - fetch: false, - }, - userUniquenessValidator: { - type: 'okapi', - records: 'users', - accumulate: 'true', - path: 'users', - fetch: false, - }, - instanceUniquenessValidator: { - type: 'okapi', - records: 'instances', - accumulate: true, - path: 'inventory/instances', - fetch: false, - }, patronBlocks: { type: 'okapi', records: 'manualblocks', @@ -474,10 +475,10 @@ class RequestsRoute extends React.Component { }, configs: { type: 'okapi', - records: 'configs', - path: 'configurations/entries', + records: 'items', + path: 'settings/entries', params: { - query: '(module==SETTINGS and configName==TLR)', + query: `(scope==${SETTINGS_SCOPES.CIRCULATION} and key==${SETTINGS_KEYS.GENERAL_TLR})`, }, }, circulationSettings: { @@ -507,6 +508,9 @@ class RequestsRoute extends React.Component { GET: PropTypes.func, POST: PropTypes.func, }), + circulationRequests: PropTypes.shape({ + POST: PropTypes.func, + }), reportRecords: PropTypes.shape({ GET: PropTypes.func, }), @@ -643,6 +647,8 @@ class RequestsRoute extends React.Component { this.expiredHoldsReportColumnHeaders = this.getColumnHeaders(expiredHoldsReportHeaders); this.state = { + isEcsTlrSettingReceived: false, + isEcsTlrSettingEnabled: false, csvReportPending: false, submitting: false, errorMessage: '', @@ -680,13 +686,23 @@ class RequestsRoute extends React.Component { } componentDidMount() { + const { stripes } = this.props; + this.setCurrentServicePointId(); + + if (stripes?.user?.user?.tenants) { + this.getEcsTlrSettings(); + } } componentDidUpdate(prevProps) { + const { stripes } = this.props; + const { + submitting, + isViewPrintDetailsEnabled, + } = this.state; const patronBlocks = get(this.props.resources, ['patronBlocks', 'records'], []); const prevBlocks = get(prevProps.resources, ['patronBlocks', 'records'], []); - const { submitting, isViewPrintDetailsEnabled } = this.state; const prevExpired = prevBlocks.filter(p => moment(moment(p.expirationDate).format()).isSameOrBefore(moment().format()) && p.expirationDate) || []; const expired = patronBlocks.filter(p => moment(moment(p.expirationDate).format()).isSameOrBefore(moment().format()) && p.expirationDate) || []; const { id: currentServicePointId } = this.getCurrentServicePointInfo(); @@ -738,6 +754,41 @@ class RequestsRoute extends React.Component { if (!isViewPrintDetailsEnabled) { this.handlePrintDetailsDisabled(); } + + if (stripes?.user?.user?.tenants && stripes.user.user !== prevProps.stripes?.user?.user) { + this.getEcsTlrSettings(); + } + } + + /* For multi data tenant environments + * ECS TLR setting has to be retrieved (Settings>Circulation>Consortium title level requests (TLR)). + * In a case if this setting is enabled we should hide Move and Duplicate buttons in action menu. */ + getEcsTlrSettings = () => { + const { stripes } = this.props; + + if (isMultiDataTenant(stripes)) { + this.findResource(RESOURCE_TYPES.ECS_TLR_SETTINGS) + .then(res => { + let isEcsTlrSettingEnabled; + + if (checkIfUserInCentralTenant(stripes)) { + isEcsTlrSettingEnabled = res?.ecsTlrFeatureEnabled; + } else { + isEcsTlrSettingEnabled = res?.circulationSettings?.[0]?.value?.enabled; + } + + this.setState({ + isEcsTlrSettingReceived: true, + isEcsTlrSettingEnabled, + }); + }) + .catch(() => { + this.setState({ + isEcsTlrSettingReceived: false, + isEcsTlrSettingEnabled: false, + }); + }); + } } handlePrintDetailsDisabled() { @@ -949,9 +1000,12 @@ class RequestsRoute extends React.Component { // idType can be 'id', 'barcode', etc. findResource(resource, value, idType = 'id') { - const query = urls[resource](value, idType); + const { stripes } = this.props; + const query = urls[resource](value, idType, stripes); - return fetch(`${this.okapiUrl}/${query}`, this.httpHeadersOptions).then(response => response.json()); + return fetch(`${this.okapiUrl}/${query}`, this.httpHeadersOptions) + .then(response => response.json()) + .catch(() => null); } toggleModal() { @@ -1048,26 +1102,34 @@ class RequestsRoute extends React.Component { this.props.mutator.activeRecord.update({ patronId: patron.id }); }; - create = (data) => { + create = (requestData) => { + const userPersonalData = cloneDeep(requestData?.requester?.personal); const query = new URLSearchParams(this.props.location.search); const mode = query.get('mode'); - return this.props.mutator.records.POST(data) - .then(() => { - this.closeLayer(); + return this.props.mutator.circulationRequests.POST(requestData) + .then((res) => { + const { + match: { + path, + }, + history, + } = this.props; + + history.push(`${path}/view/${res?.primaryRequestId || res?.id}`); this.context.sendCallout({ message: isDuplicateMode(mode) ? ( ) : ( ), }); @@ -1381,6 +1443,8 @@ class RequestsRoute extends React.Component { holdsShelfReportPending, createTitleLevelRequestsByDefault, isViewPrintDetailsEnabled, + isEcsTlrSettingReceived, + isEcsTlrSettingEnabled, } = this.state; const isPrintHoldRequestsEnabled = getPrintHoldRequestsEnabled(resources.printHoldRequests); const { name: servicePointName } = this.getCurrentServicePointInfo(); @@ -1678,6 +1742,8 @@ class RequestsRoute extends React.Component { query: resources.query, onDuplicate: this.onDuplicate, buildRecordsForHoldsShelfReport: this.buildRecordsForHoldsShelfReport, + isEcsTlrSettingReceived, + isEcsTlrSettingEnabled, }} viewRecordOnCollapse={this.viewRecordOnCollapse} viewRecordPerms="ui-requests.view" diff --git a/src/routes/RequestsRoute.test.js b/src/routes/RequestsRoute.test.js index 182c4298..7ef69477 100644 --- a/src/routes/RequestsRoute.test.js +++ b/src/routes/RequestsRoute.test.js @@ -20,6 +20,7 @@ import { CalloutContext, AppIcon, TitleManager, + checkIfUserInCentralTenant, } from '@folio/stripes/core'; import { TextLink, @@ -28,6 +29,7 @@ import { import { exportCsv, effectiveCallNumber, + getHeaderWithCredentials, } from '@folio/stripes/util'; import RequestsRoute, { @@ -46,6 +48,7 @@ import { getFullName, getInstanceQueryString, getNextSelectedRowsState, + isMultiDataTenant, } from '../utils'; import { getFormattedYears, @@ -64,6 +67,7 @@ import { MAX_RECORDS, REQUEST_OPERATIONS, INPUT_REQUEST_SEARCH_SELECTOR, + ITEM_QUERIES, PRINT_DETAILS_COLUMNS, } from '../constants'; import { historyData } from '../../test/jest/fixtures/historyData'; @@ -84,7 +88,6 @@ const testIds = { rowCheckbox: 'rowCheckbox', selectRequestCheckbox: 'selectRequestCheckbox', }; - const intlCache = createIntlCache(); const intl = createIntl( { @@ -110,6 +113,8 @@ jest.mock('../utils', () => ({ getInstanceQueryString: jest.fn(), getNextSelectedRowsState: jest.fn(), extractPickSlipRequestIds: jest.fn(), + isMultiDataTenant: jest.fn(), + generateUserName: jest.fn(), })); jest.mock('./utils', () => ({ ...jest.requireActual('./utils'), @@ -235,6 +240,12 @@ const mockedRequest = { }, id: 'requestId', }; +const userData = { + requester: { + personal: {}, + } +}; +const createRequestButtonLabel = 'Create request'; const printDetailsMockData = { printCount: 11, printEventDate: '2024-08-03T13:33:31.868Z', @@ -261,6 +272,7 @@ SearchAndSort.mockImplementation(jest.fn(({ customPaneSub, columnMapping, resultsFormatter, + onCreate, }) => { const onClickActions = () => { onDuplicate(parentResources.records.records[0]); @@ -312,6 +324,12 @@ SearchAndSort.mockImplementation(jest.fn(({ })} >onFilterChange + {actionMenu({ onToggle: jest.fn() })}
@@ -423,6 +441,9 @@ describe('RequestsRoute', () => { GET: jest.fn(), POST: jest.fn().mockResolvedValue(), }, + circulationRequests: { + POST: jest.fn().mockResolvedValue(), + }, reportRecords: { GET: jest.fn().mockReturnValueOnce(mockRecordValues).mockRejectedValue(), reset: jest.fn(), @@ -675,6 +696,138 @@ describe('RequestsRoute', () => { }); }); + describe('When single data tenant', () => { + const response = { + id: 'responseId', + }; + const props = { + ...defaultProps, + mutator: { + ...defaultProps.mutator, + circulationRequests: { + POST: jest.fn().mockResolvedValue(response), + }, + }, + }; + + beforeEach(() => { + isMultiDataTenant.mockReturnValue(false); + renderComponent(props); + }); + + it('should handle request creation', () => { + const createRequestButton = screen.getByText(createRequestButtonLabel); + + fireEvent.click(createRequestButton); + + expect(props.mutator.circulationRequests.POST).toHaveBeenCalledWith(userData); + }); + + it('should redirect to details page', async () => { + const createRequestButton = screen.getByText(createRequestButtonLabel); + + fireEvent.click(createRequestButton); + + await waitFor(() => { + expect(props.history.push).toHaveBeenCalledWith(`${props.match.path}/view/${response.id}`); + }); + }); + + it('should send callout', async () => { + const createRequestButton = screen.getByText(createRequestButtonLabel); + + sendCallout.mockClear(); + fireEvent.click(createRequestButton); + + await waitFor(() => { + expect(sendCallout).toHaveBeenCalled(); + }); + }); + }); + + describe('When multi data tenant', () => { + const requestHeaders = { test: 'test' }; + const fetchSpy = jest.fn(); + const response = { + id: 'responseId', + }; + const props = { + ...defaultProps, + mutator: { + ...defaultProps.mutator, + circulationRequests: { + POST: jest.fn().mockResolvedValue(response), + }, + }, + stripes: { + ...defaultProps.stripes, + user: { + user: { + ...defaultProps.stripes.user.user, + tenants: [{ id: 'tenantId' }], + }, + }, + }, + }; + + beforeEach(() => { + isMultiDataTenant.mockReturnValue(true); + getHeaderWithCredentials.mockReturnValue(requestHeaders); + }); + + afterEach(() => { + fetchSpy.mockClear(); + }); + + describe('When user in central tenant', () => { + beforeEach(() => { + fetchSpy.mockResolvedValueOnce({ + json: () => ({ + ecsTlrFeatureEnabled: true, + }), + }); + checkIfUserInCentralTenant.mockReturnValue(true); + global.fetch = fetchSpy; + renderComponent(props); + }); + + it('should use correct endpoint to get ecs tlr settings', () => { + expect(fetchSpy).toHaveBeenCalledWith(`${defaultProps.stripes.okapi.url}/tlr/settings`, requestHeaders); + }); + + it('should handle request creation', () => { + const createRequestButton = screen.getByText(createRequestButtonLabel); + + fireEvent.click(createRequestButton); + + expect(props.mutator.circulationRequests.POST).toHaveBeenCalledWith(userData); + }); + }); + + describe('When user in data tenant', () => { + beforeEach(() => { + fetchSpy.mockResolvedValueOnce({ + json: () => ({ + circulationSettings: [ + { + value: { + enabled: true, + }, + } + ], + }), + }); + checkIfUserInCentralTenant.mockReturnValueOnce(false); + global.fetch = fetchSpy; + renderComponent(props); + }); + + it('should use correct endpoint to get ecs tlr settings', () => { + expect(fetchSpy).toHaveBeenCalledWith(`${defaultProps.stripes.okapi.url}/circulation/settings?query=name==ecsTlrFeature`, requestHeaders); + }); + }); + }); + describe('Print pick slips', () => { afterEach(() => { jest.clearAllMocks(); @@ -1461,7 +1614,7 @@ describe('RequestsRoute', () => { describe('urls', () => { const mockedQueryValue = 'testQuery'; - const idType = 'idType'; + const idType = 'id'; beforeEach(() => { stringify.mockReturnValue(mockedQueryValue); @@ -1501,14 +1654,14 @@ describe('RequestsRoute', () => { it('should trigger "stringify" with correct argument', () => { const expectedArgument = { - query: `(${idType}=="${value[0]}" or ${idType}=="${value[1]}")`, + query: `(${ITEM_QUERIES[idType]}=="${value[0]}" or ${ITEM_QUERIES[idType]}=="${value[1]}")`, }; expect(stringify).toHaveBeenCalledWith(expectedArgument); }); it('should return correct url', () => { - const expectedResult = `inventory/items?${mockedQueryValue}`; + const expectedResult = `circulation-bff/requests/search-instances?${mockedQueryValue}`; expect(queryString).toBe(expectedResult); }); @@ -1524,14 +1677,14 @@ describe('RequestsRoute', () => { it('should trigger "stringify" with correct argument', () => { const expectedArgument = { - query: `(${idType}=="${value}")`, + query: `(${ITEM_QUERIES[idType]}=="${value}")`, }; expect(stringify).toHaveBeenCalledWith(expectedArgument); }); it('should return correct url', () => { - const expectedResult = `inventory/items?${mockedQueryValue}`; + const expectedResult = `circulation-bff/requests/search-instances?${mockedQueryValue}`; expect(queryString).toBe(expectedResult); }); @@ -1561,7 +1714,7 @@ describe('RequestsRoute', () => { }); it('should return correct url', () => { - const expectedResult = `inventory/instances?${mockedQueryValue}`; + const expectedResult = `circulation-bff/requests/search-instances?${mockedQueryValue}`; expect(queryString).toBe(expectedResult); }); @@ -1673,63 +1826,65 @@ describe('RequestsRoute', () => { }); }); - describe('holding', () => { - const value = 'value'; - let queryString; - - beforeEach(() => { - queryString = urls.holding(value, idType); - }); - - it('should trigger "stringify" with correct argument', () => { - const expectedArgument = { - query: `(${idType}=="${value}")`, - }; - - expect(stringify).toHaveBeenCalledWith(expectedArgument); - }); - - it('should return correct url', () => { - const expectedResult = `holdings-storage/holdings?${mockedQueryValue}`; - - expect(queryString).toBe(expectedResult); - }); - }); - describe('requestTypes', () => { const requesterId = 'requesterIdUrl'; const operation = REQUEST_OPERATIONS.CREATE; const itemId = 'itemIdUrl'; const instanceId = 'instanceIdUrl'; + const requestUrl = 'circulation-bff/requests/allowed-service-points'; it('should return url with "itemId"', () => { - const expectedUrl = `circulation/requests/allowed-service-points?requesterId=${requesterId}&operation=${operation}&itemId=${itemId}`; + const expectedResult = `${requestUrl}?requesterId=${requesterId}&operation=${operation}&itemId=${itemId}`; expect(urls.requestTypes({ requesterId, itemId, operation, - })).toBe(expectedUrl); + })).toBe(expectedResult); }); it('should return url with "instanceId"', () => { - const expectedUrl = `circulation/requests/allowed-service-points?requesterId=${requesterId}&operation=${operation}&instanceId=${instanceId}`; + const expectedResult = `${requestUrl}?requesterId=${requesterId}&operation=${operation}&instanceId=${instanceId}`; expect(urls.requestTypes({ requesterId, instanceId, operation, - })).toBe(expectedUrl); + })).toBe(expectedResult); }); it('should return url with "requestId"', () => { const requestId = 'requestId'; - const expectedUrl = `circulation/requests/allowed-service-points?operation=${operation}&requestId=${requestId}`; + const expectedResult = `${requestUrl}?operation=${operation}&requestId=${requestId}`; expect(urls.requestTypes({ requestId, operation, - })).toBe(expectedUrl); + })).toBe(expectedResult); + }); + }); + + describe('ecsTlrSettings', () => { + describe('When user in central tenant', () => { + it('should return correct endpoint', () => { + checkIfUserInCentralTenant.mockReturnValueOnce(true); + + const ecsTlrSettingsEndpoint = 'tlr/settings'; + const result = urls.ecsTlrSettings(); + + expect(result).toBe(ecsTlrSettingsEndpoint); + }); + }); + + describe('When user in data tenant', () => { + it('should return correct endpoint', () => { + checkIfUserInCentralTenant.mockReturnValueOnce(false); + + const ecsTlrSettingsEndpoint = 'circulation/settings?query=name==ecsTlrFeature'; + const result = urls.ecsTlrSettings(); + + expect(result).toBe(ecsTlrSettingsEndpoint); + }); }); }); }); diff --git a/src/utils.js b/src/utils.js index 2b4e961c..cdaf3962 100644 --- a/src/utils.js +++ b/src/utils.js @@ -354,13 +354,7 @@ export function parseErrorMessage(errorMessage) { )); } -export const getTlrSettings = (settings) => { - try { - return JSON.parse(settings); - } catch (error) { - return {}; - } -}; +export const getTlrSettings = (settings) => settings || {}; export const getRequestLevelValue = (value) => { return value @@ -371,15 +365,19 @@ export const getRequestLevelValue = (value) => { export const getInstanceQueryString = (hrid, id) => `("hrid"=="${hrid}" or "id"=="${id || hrid}")`; export const generateUserName = (user) => { - const { - firstName, - lastName, - middleName, - } = user; + if (user) { + const { + firstName, + lastName, + middleName, + } = user; - const shownMiddleName = middleName ? ` ${middleName}` : ''; + const shownMiddleName = middleName ? ` ${middleName}` : ''; - return `${lastName}${firstName ? ', ' + firstName + shownMiddleName : ''}`; + return `${lastName}${firstName ? ', ' + firstName + shownMiddleName : ''}`; + } + + return ''; }; export const handleKeyCommand = (handler, { disabled } = {}) => { @@ -519,6 +517,10 @@ export function resetFieldState(form, fieldName) { } } +export const isMultiDataTenant = (stripes) => { + return stripes.hasInterface('consortia') && stripes.hasInterface('ecs-tlr'); +}; + export const getRequester = (proxy, selectedUser) => { if (proxy && proxy.id !== selectedUser?.id) { return proxy; diff --git a/src/utils.test.js b/src/utils.test.js index f5ff4bb5..c46e65fb 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -34,6 +34,7 @@ import { selectedRowsNonPrintable, isPrintable, getNextSelectedRowsState, + isMultiDataTenant, getRequester, getFullName, } from './utils'; @@ -193,17 +194,13 @@ describe('getTlrSettings', () => { createTitleLevelRequestsByDefault: false, }; - it('should return parsed settings', () => { - expect(getTlrSettings(JSON.stringify(defaultSettings))).toEqual(defaultSettings); + it('should return passed settings', () => { + expect(getTlrSettings(defaultSettings)).toEqual(defaultSettings); }); it('should return empty object if nothing passed', () => { expect(getTlrSettings()).toEqual({}); }); - - it('should return empty object if invalid settings passed', () => { - expect(getTlrSettings("{'foo': 1}")).toEqual({}); - }); }); describe('getRequestLevelValue', () => { @@ -275,6 +272,10 @@ describe('generateUserName', () => { expect(generateUserName({ firstName, lastName, middleName })) .toEqual(lastName); }); + + it('should return empty string', () => { + expect(generateUserName()).toBe(''); + }); }); describe('handlekeycommand', () => { @@ -1097,6 +1098,32 @@ describe('getRequestTypeOptions', () => { }); }); +describe('isMultiDataTenant', () => { + describe('When multi data tenant', () => { + const stripes = { + hasInterface: () => true, + }; + + it('should return true', () => { + const result = isMultiDataTenant(stripes); + + expect(result).toBe(true); + }); + }); + + describe('When single data tenant', () => { + const stripes = { + hasInterface: () => false, + }; + + it('should return false', () => { + const result = isMultiDataTenant(stripes); + + expect(result).toBe(false); + }); + }); +}); + describe('getRequester', () => { const selectedUser = { id: 'selectedUserId', diff --git a/test/jest/__mock__/stripesCore.mock.js b/test/jest/__mock__/stripesCore.mock.js index a7f2092e..bc937c7b 100644 --- a/test/jest/__mock__/stripesCore.mock.js +++ b/test/jest/__mock__/stripesCore.mock.js @@ -6,6 +6,7 @@ const mockStripes = buildStripes(); jest.mock('@folio/stripes/core', () => ({ ...jest.requireActual('@folio/stripes/core'), AppContextMenu: ({ children }) => (typeof children === 'function' ? children(jest.fn()) : children), + checkIfUserInCentralTenant: jest.fn(), IntlConsumer: jest.fn(({ children }) => { const intl = { formatMessage: jest.fn(({ id }) => id),