diff --git a/CHANGELOG.md b/CHANGELOG.md index afe79524..153ae1b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,11 @@ * 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. +* Implement feature toggle for ECS and not ECS envs. Refs UIREQ-1171. ## [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) + * Fix DOMPurify import. Refs UIREQ-1180. ## [10.0.0] (https://github.com/folio-org/ui-requests/tree/v10.0.0) (2024-10-31) diff --git a/package.json b/package.json index 1bd5cbba..6e4797e0 100644 --- a/package.json +++ b/package.json @@ -58,6 +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" ] @@ -122,6 +123,8 @@ "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", @@ -143,6 +146,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", diff --git a/src/deprecated/components/InstanceInformation/InstanceInformation.css b/src/deprecated/components/InstanceInformation/InstanceInformation.css new file mode 100644 index 00000000..97c034ab --- /dev/null +++ b/src/deprecated/components/InstanceInformation/InstanceInformation.css @@ -0,0 +1,3 @@ +.enterButton { + margin-top: 25px; +} diff --git a/src/deprecated/components/InstanceInformation/InstanceInformation.js b/src/deprecated/components/InstanceInformation/InstanceInformation.js new file mode 100644 index 00000000..c6eafab7 --- /dev/null +++ b/src/deprecated/components/InstanceInformation/InstanceInformation.js @@ -0,0 +1,265 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Field } from 'react-final-form'; +import { isNull } from 'lodash'; + +import { + Button, + Col, + Icon, + Row, + TextField, +} from '@folio/stripes/components'; +import { Pluggable } from '@folio/stripes/core'; + +import { + BASE_SPINNER_PROPS, + ENTER_EVENT_KEY, + REQUEST_FORM_FIELD_NAMES, +} from '../../../constants'; +import { TitleInformation } from '../../../components'; +import { + isFormEditing, + memoizeValidation, +} from '../../../utils'; + +import css from './InstanceInformation.css'; + +export const INSTANCE_SEGMENT_FOR_PLUGIN = 'instances'; + +class InstanceInformation extends Component { + static propTypes = { + triggerValidation: PropTypes.func.isRequired, + findInstance: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + form: PropTypes.object.isRequired, + 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, + }; + + constructor(props) { + super(props); + + this.state = { + shouldValidateId: false, + isInstanceClicked: false, + isInstanceBlurred: false, + validatedId: null, + }; + } + + validate = memoizeValidation(async (instanceId) => { + const { + selectedInstance, + findInstance, + } = this.props; + const { shouldValidateId } = this.state; + + if (!instanceId || (!instanceId && !selectedInstance?.id)) { + return ; + } + + if (instanceId && shouldValidateId) { + this.setState({ shouldValidateId: false }); + + const instance = await findInstance(instanceId, null, true); + + return !instance + ? + : undefined; + } + + return undefined; + }); + + handleChange = (event) => { + const { form } = this.props; + const { + isInstanceClicked, + isInstanceBlurred, + validatedId, + } = this.state; + const instanceId = event.target.value; + + if (isInstanceClicked || isInstanceBlurred) { + this.setState({ + isInstanceClicked: false, + isInstanceBlurred: false, + }); + } + + if (!isNull(validatedId)) { + this.setState({ validatedId: null }); + } + + form.change(REQUEST_FORM_FIELD_NAMES.INSTANCE_HRID, instanceId); + }; + + handleBlur = (input) => () => { + const { triggerValidation } = this.props; + const { validatedId } = this.state; + + if (input.value && input.value !== validatedId) { + this.setState({ + shouldValidateId: true, + isInstanceBlurred: true, + validatedId: input.value, + }, () => { + input.onBlur(); + triggerValidation(); + }); + } else if (!input.value) { + input.onBlur(); + } + }; + + handleClick = (eventKey) => { + const { + values, + onSetSelectedInstance, + findInstance, + triggerValidation, + } = this.props; + const instanceId = values.instance?.hrid; + + if (instanceId) { + onSetSelectedInstance(null); + this.setState(({ + isInstanceClicked: true, + })); + findInstance(instanceId); + + if (eventKey === ENTER_EVENT_KEY) { + this.setState({ + shouldValidateId: true, + }, triggerValidation); + } + } + }; + + onKeyDown = (e) => { + if (e.key === ENTER_EVENT_KEY && !e.shiftKey) { + e.preventDefault(); + this.handleClick(e.key); + } + }; + + render() { + const { + request, + selectedInstance, + findInstance, + submitting, + values, + isLoading, + instanceRequestCount, + instanceId, + } = this.props; + const { + isInstanceClicked, + isInstanceBlurred, + } = this.state; + const isEditForm = isFormEditing(request); + const titleLevelRequestsCount = request?.titleRequestCount || instanceRequestCount; + const isTitleInfoVisible = selectedInstance && !isLoading; + + return ( + + + { + !isEditForm && + <> + + + + {placeholder => { + const key = values.keyOfInstanceIdField ?? 0; + + return ( + + {({ input, meta }) => { + const selectInstanceError = meta.touched && meta.error; + const instanceDoesntExistError = (isInstanceClicked || isInstanceBlurred) && meta.error; + const error = selectInstanceError || instanceDoesntExistError || null; + + return ( + } + error={error} + onChange={this.handleChange} + onBlur={this.handleBlur(input)} + onKeyDown={this.onKeyDown} + /> + ); + }} + + ); + }} + + + + + + + + + } + selectInstance={(instanceFromPlugin) => findInstance(instanceFromPlugin.hrid)} + config={{ + availableSegments: [{ + name: INSTANCE_SEGMENT_FOR_PLUGIN, + }], + }} + /> + + + + } + { + isLoading && + } + { + isTitleInfoVisible && + + } + + + ); + } +} + +export default InstanceInformation; diff --git a/src/deprecated/components/InstanceInformation/InstanceInformation.test.js b/src/deprecated/components/InstanceInformation/InstanceInformation.test.js new file mode 100644 index 00000000..e5fd6abf --- /dev/null +++ b/src/deprecated/components/InstanceInformation/InstanceInformation.test.js @@ -0,0 +1,671 @@ +import { useState } from 'react'; +import { Field } from 'react-final-form'; + +import { + render, + screen, + fireEvent, + cleanup, + waitFor, +} from '@folio/jest-config-stripes/testing-library/react'; + +import { + Icon, + TextField, +} from '@folio/stripes/components'; +import { Pluggable } from '@folio/stripes/core'; + +import InstanceInformation, { + INSTANCE_SEGMENT_FOR_PLUGIN, +} from './InstanceInformation'; +import { TitleInformation } from '../../../components'; +import { isFormEditing } from '../../../utils'; +import { + BASE_SPINNER_PROPS, + ENTER_EVENT_KEY, + REQUEST_FORM_FIELD_NAMES, +} from '../../../constants'; + +jest.mock('../../../utils', () => ({ + memoizeValidation: (fn) => () => fn, + isFormEditing: jest.fn(() => false), +})); +jest.mock('../../../components', () => ({ + TitleInformation: jest.fn(() =>
TitleInformation
), +})); + +const basicProps = { + triggerValidation: jest.fn(), + findInstance: jest.fn(() => null), + onSetSelectedInstance: jest.fn(), + form: { + change: jest.fn(), + }, + values: { + instance: { + hrid: 'hrid', + }, + keyOfInstanceIdField: 1, + }, + request: { + id: 'requestId', + }, + selectedInstance: { + title: 'instance title', + contributors: {}, + publication: {}, + editions: {}, + identifiers: {}, + }, + instanceRequestCount: 1, + instanceId: 'instanceId', + isLoading: false, + submitting: false, +}; +const labelIds = { + scanOrEnterBarcode: 'ui-requests.instance.scanOrEnterBarcode', + instanceHrid: 'ui-requests.instance.value', + enterButton: 'ui-requests.enter', + selectInstanceRequired: 'ui-requests.errors.selectInstanceRequired', + instanceUuidOrHridDoesNotExist: 'ui-requests.errors.instanceUuidOrHridDoesNotExist', + titleLookupPlugin: 'ui-requests.titleLookupPlugin', +}; +const testIds = { + instanceHridField: 'instanceHridField', + errorMessage: 'errorMessage', +}; +const renderInstanceInfoWithHrid = (onBlur) => { + Field.mockImplementation(jest.fn(({ + children, + 'data-testid': testId, + validate, + }) => { + return children({ + meta: {}, + input: { + validate, + 'data-testid': testId, + value: 'hrid', + onBlur, + }, + }); + })); + + render( + + ); +}; + +describe('InstanceInformation', () => { + afterEach(() => { + basicProps.onSetSelectedInstance.mockClear(); + Field.mockClear(); + cleanup(); + }); + + describe('when "isFormEditing" returns false', () => { + beforeEach(() => { + render( + + ); + }); + + it('should render "scanOrEnterBarcode" placeholder', () => { + const scanOrEnterBarcodePlaceholder = screen.getByPlaceholderText(labelIds.scanOrEnterBarcode); + + expect(scanOrEnterBarcodePlaceholder).toBeVisible(); + }); + + it('should render instance hrid "Field" with correct props', () => { + const expectedProps = { + name: REQUEST_FORM_FIELD_NAMES.INSTANCE_HRID, + validate: expect.any(Function), + validateFields: [], + }; + + expect(Field).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + + it('should render instance hrid label', () => { + const instanceHridLabel = screen.getByText(labelIds.instanceHrid); + + expect(instanceHridLabel).toBeVisible(); + }); + + it('should trigger "findInstance" when Enter key is pressed', () => { + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.keyDown(instanceHridField, { key: ENTER_EVENT_KEY }); + + expect(basicProps.findInstance).toHaveBeenCalledWith(basicProps.values.instance.hrid); + }); + + it('should not trigger "findInstance" when Control key is pressed', () => { + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.keyDown(instanceHridField, { key: 'Control' }); + + expect(basicProps.findInstance).not.toHaveBeenCalledWith(); + }); + + it('should trigger "form.change" with correct arguments', () => { + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + const event = { + target: { + value: 'instanceHrid', + }, + }; + + fireEvent.change(instanceHridField, event); + + expect(basicProps.form.change).toHaveBeenCalledWith(REQUEST_FORM_FIELD_NAMES.INSTANCE_HRID, event.target.value); + }); + + it('should render "TextField" with correct props', () => { + const expectedProps = { + required: true, + error: null, + placeholder: [labelIds.scanOrEnterBarcode], + onChange: expect.any(Function), + onBlur: expect.any(Function), + onKeyDown: expect.any(Function), + }; + + expect(TextField).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + + it('should render "TextField" with validation error', () => { + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + const enterButton = screen.getByText(labelIds.enterButton); + const event = { + target: { + value: 'instanceHrid', + }, + }; + const error = 'error'; + + Field.mockImplementationOnce(jest.fn(({ + children, + 'data-testid': testId, + validate, + }) => { + return children({ + meta: { + error, + touched: true, + }, + input: { + validate, + 'data-testid': testId, + }, + }); + })); + + fireEvent.click(enterButton); + fireEvent.change(instanceHridField, event); + + expect(TextField).toHaveBeenCalledWith(expect.objectContaining({ error }), {}); + }); + + it('should render "Pluggable" with correct props', () => { + const expectedProps = { + searchButtonStyle: 'link', + type: 'find-instance', + selectInstance: expect.any(Function), + config: { + availableSegments: [{ + name: INSTANCE_SEGMENT_FOR_PLUGIN, + }], + }, + }; + + expect(Pluggable).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + + it('should render title lookup plugin label', () => { + const titleLookupPluginLabel = screen.getByText(labelIds.titleLookupPlugin); + + expect(titleLookupPluginLabel).toBeVisible(); + }); + + it('should trigger "findInstance" with correct argument', () => { + const hrid = 'hrid'; + const searchButtonLabel = 'Search'; + const searchButton = screen.getByText(searchButtonLabel); + + fireEvent.click(searchButton); + + expect(basicProps.findInstance).toHaveBeenCalledWith(hrid); + }); + }); + + describe('when "isFormEditing" returns true', () => { + beforeEach(() => { + isFormEditing.mockReturnValueOnce(true); + + render( + + ); + }); + + it('should not render "scanOrEnterBarcode" placeholder', () => { + const scanOrEnterBarcodePlaceholder = screen.queryByPlaceholderText(labelIds.scanOrEnterBarcode); + + expect(scanOrEnterBarcodePlaceholder).not.toBeInTheDocument(); + }); + + it('should not render instance hrid field', () => { + const instanceHridField = screen.queryByTestId(testIds.instanceHridField); + + expect(instanceHridField).not.toBeInTheDocument(); + }); + + it('should not render instance hrid label', () => { + const instanceHridLabel = screen.queryByText(labelIds.instanceHrid); + + expect(instanceHridLabel).not.toBeInTheDocument(); + }); + }); + + describe('handleBlur', () => { + const onBlur = jest.fn(); + + afterEach(() => { + onBlur.mockClear(); + }); + + it('should trigger "input.onBlur" if instance hrid is presented', () => { + renderInstanceInfoWithHrid(onBlur); + + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.click(instanceHridField); + fireEvent.blur(instanceHridField); + + expect(onBlur).toHaveBeenCalled(); + }); + + it('should trigger "input.onBlur" if there is no instance hrid', () => { + Field.mockImplementation(jest.fn(({ + children, + 'data-testid': testId, + validate, + }) => { + return children({ + meta: {}, + input: { + validate, + 'data-testid': testId, + value: '', + onBlur, + }, + }); + })); + + render( + + ); + + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.click(instanceHridField); + fireEvent.blur(instanceHridField); + + expect(onBlur).toHaveBeenCalled(); + }); + + it('should not trigger "input.onBlur" if instance hrid was validated previously', () => { + renderInstanceInfoWithHrid(onBlur); + + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + // first input focus + fireEvent.click(instanceHridField); + fireEvent.blur(instanceHridField); + onBlur.mockClear(); + + // second input focus after validation of initial value + fireEvent.click(instanceHridField); + fireEvent.blur(instanceHridField); + + expect(onBlur).not.toHaveBeenCalled(); + }); + }); + + describe('Validation', () => { + afterEach(() => { + basicProps.findInstance.mockClear(); + TextField.mockClear(); + }); + + beforeEach(() => { + TextField.mockImplementation(({ + onChange, + validate, + ...rest + }) => { + const [error, setError] = useState(''); + const handleChange = async (e) => { + setError(await validate(e.target.value)); + onChange(e); + }; + + return ( +
+ + {error} +
+ ); + }); + }); + + describe('when instance hrid is not presented', () => { + const event = { + target: { + value: '', + }, + }; + + it('should not render error message', async () => { + render( + + ); + + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.change(instanceHridField, event); + + await waitFor(() => { + const errorMessage = screen.getByTestId(testIds.errorMessage); + + expect(errorMessage).toBeEmpty(); + }); + }); + + it('should render "selectInstanceRequired" error message', async () => { + const props = { + ...basicProps, + selectedInstance: { + id: 'hrid', + }, + }; + + render( + + ); + + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.change(instanceHridField, event); + + await waitFor(() => { + const errorMessage = screen.queryByText(labelIds.selectInstanceRequired); + + expect(errorMessage).toBeVisible(); + }); + }); + }); + + describe('when instance hrid is presented', () => { + const event = { + target: { + value: 'instanceId', + }, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should not render error message', async () => { + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.change(instanceHridField, event); + + await waitFor(() => { + const errorMessage = screen.getByTestId(testIds.errorMessage); + + expect(errorMessage).toBeEmpty(); + }); + }); + + it('should render "instanceUuidOrHridDoesNotExist" error message', async () => { + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + fireEvent.keyDown(instanceHridField, { key: ENTER_EVENT_KEY }); + fireEvent.change(instanceHridField, event); + + await waitFor(() => { + const errorMessage = screen.queryByText(labelIds.instanceUuidOrHridDoesNotExist); + + expect(errorMessage).toBeVisible(); + }); + }); + + it('should not render error message if instance found', async () => { + const instanceHridField = screen.getByTestId(testIds.instanceHridField); + + basicProps.findInstance.mockReturnValue({}); + fireEvent.keyDown(instanceHridField, { key: ENTER_EVENT_KEY }); + fireEvent.change(instanceHridField, event); + + await waitFor(() => { + const errorMessage = screen.getByTestId(testIds.errorMessage); + + expect(errorMessage).toBeEmpty(); + }); + }); + }); + }); + + describe('"Enter" button', () => { + describe('when instance hrid is presented', () => { + beforeEach(() => { + render( + + ); + }); + + it('should render "Enter" button', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + expect(enterButton).toBeVisible(); + }); + + it('should trigger "onSetSelectedInstance" with correct argument', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + fireEvent.click(enterButton); + + expect(basicProps.onSetSelectedInstance).toHaveBeenCalledWith(null); + }); + + it('should trigger "findInstance" with correct argument', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + fireEvent.click(enterButton); + + expect(basicProps.findInstance).toHaveBeenCalledWith(basicProps.values.instance.hrid); + }); + }); + + describe('when instance hrid is not presented', () => { + const props = { + ...basicProps, + values: { + instance: { + hrid: '', + }, + }, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should not trigger "onSetSelectedInstance"', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + fireEvent.click(enterButton); + + expect(basicProps.onSetSelectedInstance).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Spinner', () => { + afterEach(() => { + Icon.mockClear(); + }); + + describe('when data is loading', () => { + const props = { + ...basicProps, + isLoading: true, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should render loading "Icon" with correct props', () => { + expect(Icon).toHaveBeenCalledWith(BASE_SPINNER_PROPS, {}); + }); + }); + + describe('when data is not loading', () => { + beforeEach(() => { + render( + + ); + }); + + it('should not render loading "Icon"', () => { + expect(Icon).not.toHaveBeenCalled(); + }); + }); + }); + + describe('TitleInformation', () => { + afterEach(() => { + TitleInformation.mockClear(); + }); + + 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 = { + ...basicProps, + request: { + ...basicProps.request, + instanceId, + }, + }; + const expectedProps = { + instanceId, + }; + + render( + + ); + + expect(TitleInformation).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + + it('should render "TitleInformation" with "selectedInstance.id"', () => { + const selectedInstanceId = 'selectedInstanceId'; + const props = { + ...basicProps, + selectedInstance: { + ...basicProps.selectedInstance, + id: selectedInstanceId, + }, + }; + const expectedProps = { + instanceId: selectedInstanceId, + }; + + render( + + ); + + expect(TitleInformation).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + }); + + describe('when instance is not selected', () => { + const props = { + ...basicProps, + selectedInstance: undefined, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should not render "TitleInformation"', () => { + expect(TitleInformation).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/deprecated/components/ItemInformation/ItemInformation.css b/src/deprecated/components/ItemInformation/ItemInformation.css new file mode 100644 index 00000000..97c034ab --- /dev/null +++ b/src/deprecated/components/ItemInformation/ItemInformation.css @@ -0,0 +1,3 @@ +.enterButton { + margin-top: 25px; +} diff --git a/src/deprecated/components/ItemInformation/ItemInformation.js b/src/deprecated/components/ItemInformation/ItemInformation.js new file mode 100644 index 00000000..58e517e3 --- /dev/null +++ b/src/deprecated/components/ItemInformation/ItemInformation.js @@ -0,0 +1,250 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Field } from 'react-final-form'; +import { isNull } from 'lodash'; + +import { + Button, + Col, + Icon, + Row, + TextField, +} from '@folio/stripes/components'; + +import { + REQUEST_FORM_FIELD_NAMES, + RESOURCE_KEYS, + ENTER_EVENT_KEY, + BASE_SPINNER_PROPS, +} from '../../../constants'; +import ItemDetail from '../../../ItemDetail'; +import { + isFormEditing, + memoizeValidation, +} from '../../../utils'; + +import css from './ItemInformation.css'; + +class ItemInformation extends Component { + static propTypes = { + triggerValidation: PropTypes.func.isRequired, + findItem: PropTypes.func.isRequired, + form: PropTypes.object.isRequired, + values: PropTypes.object.isRequired, + 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, + selectedLoan: PropTypes.object, + selectedItem: PropTypes.object, + }; + + constructor(props) { + super(props); + + this.state = { + shouldValidateBarcode: false, + isItemClicked: false, + isItemBlurred: false, + validatedBarcode: null, + }; + } + + validate = memoizeValidation(async (barcode) => { + const { + isItemIdRequest, + findItem, + } = this.props; + const { shouldValidateBarcode } = this.state; + + if (isItemIdRequest && !barcode) { + return undefined; + } + + if (!barcode && !isItemIdRequest) { + return ; + } + + if (barcode && shouldValidateBarcode) { + this.setState({ shouldValidateBarcode: false }); + + const item = await findItem(RESOURCE_KEYS.barcode, barcode, true); + + return !item + ? + : undefined; + } + + return undefined; + }); + + handleChange = (event) => { + const { form } = this.props; + const { + isItemClicked, + isItemBlurred, + validatedBarcode, + } = this.state; + const barcode = event.target.value; + + if (isItemClicked || isItemBlurred) { + this.setState({ + isItemClicked: false, + isItemBlurred: false, + }); + } + + if (!isNull(validatedBarcode)) { + this.setState({ validatedBarcode: null }); + } + + form.change(REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, barcode); + }; + + handleBlur = (input) => () => { + const { triggerValidation } = this.props; + const { validatedBarcode } = this.state; + + if (input.value && input.value !== validatedBarcode) { + this.setState({ + shouldValidateBarcode: true, + isItemBlurred: true, + validatedBarcode: input.value, + }, () => { + input.onBlur(); + triggerValidation(); + }); + } else if (!input.value) { + input.onBlur(); + } + } + + onKeyDown = (e) => { + if (e.key === ENTER_EVENT_KEY && !e.shiftKey) { + e.preventDefault(); + this.handleClick(e.key); + } + }; + + handleClick = (eventKey) => { + const { + onSetSelectedItem, + findItem, + triggerValidation, + values, + } = this.props; + const barcode = values.item?.barcode; + + if (barcode) { + onSetSelectedItem(null); + this.setState(({ + isItemClicked: true, + })); + + findItem(RESOURCE_KEYS.barcode, barcode); + + if (eventKey === ENTER_EVENT_KEY) { + this.setState({ + shouldValidateBarcode: true, + }, triggerValidation); + } + } + } + + render() { + const { + values, + submitting, + isLoading, + selectedItem, + request, + instanceId, + selectedLoan, + itemRequestCount, + } = this.props; + const { + isItemClicked, + isItemBlurred, + } = this.state; + const isEditForm = isFormEditing(request); + + return ( + + + { + !isEditForm && + + + + {placeholder => { + const key = values.keyOfItemBarcodeField ?? 0; + + return ( + + {({ input, meta }) => { + const selectItemError = meta.touched && meta.error; + const itemDoesntExistError = (isItemClicked || isItemBlurred) && meta.error; + const error = meta.submitError || selectItemError || itemDoesntExistError || null; + + return ( + } + error={error} + onChange={this.handleChange} + onBlur={this.handleBlur(input)} + onKeyDown={this.onKeyDown} + /> + ); + }} + + ); + }} + + + + + + + } + { + isLoading && + } + { + selectedItem && + + } + + + ); + } +} + +export default ItemInformation; diff --git a/src/deprecated/components/ItemInformation/ItemInformation.test.js b/src/deprecated/components/ItemInformation/ItemInformation.test.js new file mode 100644 index 00000000..5e6a7347 --- /dev/null +++ b/src/deprecated/components/ItemInformation/ItemInformation.test.js @@ -0,0 +1,589 @@ +import { useState } from 'react'; +import { Field } from 'react-final-form'; + +import { + render, + screen, + fireEvent, + cleanup, + waitFor, +} from '@folio/jest-config-stripes/testing-library/react'; +import { + Icon, + TextField, +} from '@folio/stripes/components'; + +import ItemInformation from './ItemInformation'; +import ItemDetail from '../../../ItemDetail'; +import { isFormEditing } from '../../../utils'; +import { + REQUEST_FORM_FIELD_NAMES, + RESOURCE_KEYS, + ENTER_EVENT_KEY, + BASE_SPINNER_PROPS, +} from '../../../constants'; + +jest.mock('../../../utils', () => ({ + isFormEditing: jest.fn(() => false), + memoizeValidation: (fn) => () => fn, +})); +jest.mock('../../../ItemDetail', () => jest.fn(() =>
Item Details
)); + +const basicProps = { + triggerValidation: jest.fn(), + findItem: jest.fn(() => null), + onSetSelectedItem: jest.fn(), + form: { + change: jest.fn(), + }, + values: { + item: { + barcode: 'itemBarcode', + }, + keyOfItemBarcodeField: 1, + }, + request: { + id: 'requestId', + }, + selectedLoan: {}, + selectedItem: {}, + itemRequestCount: 1, + instanceId: 'instanceId', + isLoading: false, + submitting: false, + isItemIdRequest: true, +}; +const labelIds = { + scanOrEnterBarcode: 'ui-requests.item.scanOrEnterBarcode', + itemBarcode: 'ui-requests.item.barcode', + enterButton: 'ui-requests.enter', + selectItemRequired: 'ui-requests.errors.selectItemRequired', + itemBarcodeDoesNotExist: 'ui-requests.errors.itemBarcodeDoesNotExist', +}; +const testIds = { + itemBarcodeField: 'itemBarcodeField', + errorMessage: 'errorMessage', +}; +const renderItemInfoWithBarcode = (onBlur) => { + Field.mockImplementation(jest.fn(({ + children, + 'data-testid': testId, + validate, + }) => { + return children({ + meta: {}, + input: { + validate, + 'data-testid': testId, + value: 'itemBarcode', + onBlur, + }, + }); + })); + + render( + + ); +}; + +describe('ItemInformation', () => { + afterEach(() => { + basicProps.onSetSelectedItem.mockClear(); + Field.mockClear(); + cleanup(); + }); + + describe('when "isFormEditing" returns false', () => { + beforeEach(() => { + render( + + ); + }); + + it('should render "scanOrEnterBarcode" placeholder', () => { + const scanOrEnterBarcodePlaceholder = screen.getByPlaceholderText(labelIds.scanOrEnterBarcode); + + expect(scanOrEnterBarcodePlaceholder).toBeVisible(); + }); + + it('should render item barcode field', () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + expect(itemBarcodeField).toBeVisible(); + }); + + it('should render item barcode "Field" with correct props', () => { + const expectedProps = { + name: REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, + validate: expect.any(Function), + validateFields: [], + }; + + expect(Field).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + + it('should render item barcode label', () => { + const itemBarcodeLabel = screen.getByText(labelIds.itemBarcode); + + expect(itemBarcodeLabel).toBeVisible(); + }); + + it('should trigger "findItem" when Enter key is pressed', () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.keyDown(itemBarcodeField, { key: ENTER_EVENT_KEY }); + + expect(basicProps.findItem).toHaveBeenCalledWith(RESOURCE_KEYS.barcode, basicProps.values.item.barcode); + }); + + it('should not trigger "findItem" when Control key is pressed', () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.keyDown(itemBarcodeField, { key: 'Control' }); + + expect(basicProps.findItem).not.toHaveBeenCalledWith(); + }); + + it('should trigger "form.change" with correct arguments', () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + const event = { + target: { + value: 'itemBarcode', + }, + }; + + fireEvent.change(itemBarcodeField, event); + + expect(basicProps.form.change).toHaveBeenCalledWith(REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, event.target.value); + }); + + it('should render "TextField" with correct props', () => { + const expectedProps = { + required: true, + error: null, + onChange: expect.any(Function), + onBlur: expect.any(Function), + onKeyDown: expect.any(Function), + }; + + expect(TextField).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + + it('should render "TextField" with validation error', () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + const enterButton = screen.getByText(labelIds.enterButton); + const event = { + target: { + value: 'itemBarcode', + }, + }; + const error = 'error'; + + Field.mockImplementationOnce(jest.fn(({ + children, + 'data-testid': testId, + validate, + }) => { + return children({ + meta: { + error: 'error', + touched: true, + }, + input: { + validate, + 'data-testid': testId, + }, + }); + })); + + fireEvent.click(enterButton); + fireEvent.change(itemBarcodeField, event); + + expect(TextField).toHaveBeenCalledWith(expect.objectContaining({ error }), {}); + }); + }); + + describe('when "isFormEditing" returns true', () => { + beforeEach(() => { + isFormEditing.mockReturnValueOnce(true); + + render( + + ); + }); + + it('should not render "scanOrEnterBarcode" placeholder', () => { + const scanOrEnterBarcodePlaceholder = screen.queryByPlaceholderText(labelIds.scanOrEnterBarcode); + + expect(scanOrEnterBarcodePlaceholder).not.toBeInTheDocument(); + }); + + it('should not render item barcode field', () => { + const itemBarcodeField = screen.queryByTestId(testIds.itemBarcodeField); + + expect(itemBarcodeField).not.toBeInTheDocument(); + }); + + it('should not render item barcode label', () => { + const itemBarcodeLabel = screen.queryByText(labelIds.itemBarcode); + + expect(itemBarcodeLabel).not.toBeInTheDocument(); + }); + }); + + describe('handleBlur', () => { + const onBlur = jest.fn(); + + afterEach(() => { + onBlur.mockClear(); + }); + + it('should trigger "input.onBlur" if item barcode is presented', () => { + renderItemInfoWithBarcode(onBlur); + + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.click(itemBarcodeField); + fireEvent.blur(itemBarcodeField); + + expect(onBlur).toHaveBeenCalled(); + }); + + it('should trigger "input.onBlur" if there is no item barcode', () => { + Field.mockImplementation(jest.fn(({ + children, + 'data-testid': testId, + validate, + }) => { + return children({ + meta: {}, + input: { + validate, + 'data-testid': testId, + value: '', + onBlur, + }, + }); + })); + + render( + + ); + + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.click(itemBarcodeField); + fireEvent.blur(itemBarcodeField); + + expect(onBlur).toHaveBeenCalled(); + }); + + it('should not trigger "input.onBlur" if item barcode was validated previously', () => { + renderItemInfoWithBarcode(onBlur); + + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + // first input focus + fireEvent.click(itemBarcodeField); + fireEvent.blur(itemBarcodeField); + onBlur.mockClear(); + + // second input focus after validation of initial value + fireEvent.click(itemBarcodeField); + fireEvent.blur(itemBarcodeField); + + expect(onBlur).not.toHaveBeenCalled(); + }); + }); + + describe('Validation', () => { + afterEach(() => { + TextField.mockClear(); + basicProps.findItem.mockClear(); + }); + + beforeEach(() => { + TextField.mockImplementation(jest.fn(({ + onChange, + validate, + ...rest + }) => { + const [error, setError] = useState(''); + const handleChange = async (e) => { + setError(await validate(e.target.value)); + onChange(e); + }; + + return ( +
+ + {error} +
+ ); + })); + }); + + describe('when "barcode" is not presented', () => { + const event = { + target: { + value: '', + }, + }; + + it('should not render error message', async () => { + render( + + ); + + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.change(itemBarcodeField, event); + + await waitFor(() => { + const errorMessage = screen.getByTestId(testIds.errorMessage); + + expect(errorMessage).toBeEmpty(); + }); + }); + + it('should render "selectItemRequired" error message', async () => { + const props = { + ...basicProps, + isItemIdRequest: false, + }; + + render( + + ); + + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.change(itemBarcodeField, event); + + await waitFor(() => { + const errorMessage = screen.queryByText(labelIds.selectItemRequired); + + expect(errorMessage).toBeVisible(); + }); + }); + }); + + describe('when "barcode" is presented', () => { + const event = { + target: { + value: 'barcode', + }, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should not render error message', async () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.change(itemBarcodeField, event); + + await waitFor(() => { + const errorMessage = screen.getByTestId(testIds.errorMessage); + + expect(errorMessage).toBeEmpty(); + }); + }); + + it('should render "itemBarcodeDoesNotExist" error message', async () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + fireEvent.keyDown(itemBarcodeField, { key: ENTER_EVENT_KEY }); + fireEvent.change(itemBarcodeField, event); + + await waitFor(() => { + const errorMessage = screen.queryByText(labelIds.itemBarcodeDoesNotExist); + + expect(errorMessage).toBeVisible(); + }); + }); + + it('should not render error message if item found', async () => { + const itemBarcodeField = screen.getByTestId(testIds.itemBarcodeField); + + basicProps.findItem.mockReturnValue({}); + fireEvent.keyDown(itemBarcodeField, { key: ENTER_EVENT_KEY }); + fireEvent.change(itemBarcodeField, event); + + await waitFor(() => { + const errorMessage = screen.getByTestId(testIds.errorMessage); + + expect(errorMessage).toBeEmpty(); + }); + }); + }); + }); + + describe('"Enter" button', () => { + describe('when barcode is presented', () => { + beforeEach(() => { + render( + + ); + }); + + it('should render "Enter" button', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + expect(enterButton).toBeVisible(); + }); + + it('should trigger "onSetSelectedItem" with correct argument', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + fireEvent.click(enterButton); + + expect(basicProps.onSetSelectedItem).toHaveBeenCalledWith(null); + }); + + it('should trigger "findItem" with correct arguments', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + fireEvent.click(enterButton); + + expect(basicProps.findItem).toHaveBeenCalledWith(RESOURCE_KEYS.barcode, basicProps.values.item.barcode); + }); + }); + + describe('when barcode is not presented', () => { + const props = { + ...basicProps, + values: { + item: { + barcode: '', + }, + }, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should not trigger "onSetSelectedItem"', () => { + const enterButton = screen.getByText(labelIds.enterButton); + + fireEvent.click(enterButton); + + expect(basicProps.onSetSelectedItem).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Spinner', () => { + afterEach(() => { + Icon.mockClear(); + }); + + describe('when data is loading', () => { + const props = { + ...basicProps, + isLoading: true, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should render loading "Icon" with correct props', () => { + expect(Icon).toHaveBeenCalledWith(BASE_SPINNER_PROPS, {}); + }); + }); + + describe('when data is not loading', () => { + beforeEach(() => { + render( + + ); + }); + + it('should not render loading "Icon"', () => { + expect(Icon).not.toHaveBeenCalled(); + }); + }); + }); + + describe('ItemDetails', () => { + afterEach(() => { + ItemDetail.mockClear(); + }); + + describe('when item is selected', () => { + beforeEach(() => { + render( + + ); + }); + + it('should render "ItemDetail" with correct props', () => { + const expectedProps = { + request: basicProps.request, + currentInstanceId: basicProps.instanceId, + item: basicProps.selectedItem, + loan: basicProps.selectedLoan, + requestCount: basicProps.itemRequestCount, + }; + + expect(ItemDetail).toHaveBeenCalledWith(expectedProps, {}); + }); + }); + + describe('when item is not selected', () => { + const props = { + ...basicProps, + selectedItem: undefined, + }; + + beforeEach(() => { + render( + + ); + }); + + it('should not render "ItemDetail"', () => { + expect(ItemDetail).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/deprecated/components/ItemsDialog/ItemsDialog.css b/src/deprecated/components/ItemsDialog/ItemsDialog.css new file mode 100644 index 00000000..00fa56d4 --- /dev/null +++ b/src/deprecated/components/ItemsDialog/ItemsDialog.css @@ -0,0 +1,4 @@ +.content { + padding: 0px; + margin: 0px; +} diff --git a/src/deprecated/components/ItemsDialog/ItemsDialog.js b/src/deprecated/components/ItemsDialog/ItemsDialog.js new file mode 100644 index 00000000..054b29ac --- /dev/null +++ b/src/deprecated/components/ItemsDialog/ItemsDialog.js @@ -0,0 +1,283 @@ +import React, { + useState, + useLayoutEffect, + useMemo, +} from 'react'; +import PropTypes from 'prop-types'; +import { + get, + noop, + countBy, + chunk, +} from 'lodash'; +import { + useIntl, + FormattedMessage, +} from 'react-intl'; + +import { + Modal, + MultiColumnList, + Pane, + Paneset, +} from '@folio/stripes/components'; +import { stripesConnect } from '@folio/stripes/core'; + +import { + itemStatuses, + itemStatusesTranslations, + requestableItemStatuses, + MAX_RECORDS, + OPEN_REQUESTS_STATUSES, +} from '../../../constants'; +import { Loading } from '../../../components'; +import { getStatusQuery } from '../../../routes/utils'; + +import css from './ItemsDialog.css'; + +export const COLUMN_NAMES = [ + 'barcode', + 'itemStatus', + 'requestQueue', + 'location', + 'materialType', + 'loanType', +]; + +export const COLUMN_WIDTHS = { + barcode: '16%', + itemStatus: '16%', + requestQueue: '16%', + location: '16%', + materialType: '16%', + loanType: '16%', +}; + +export const COLUMN_MAP = { + barcode: , + itemStatus: , + requestQueue: , + location: , + materialType: , + loanType: , +}; + +export const formatter = { + itemStatus: item => , + location: item => get(item, 'effectiveLocation.name', ''), + materialType: item => item.materialType.name, + loanType: item => (item.temporaryLoanType ? get(item, 'temporaryLoanType.name', '') : get(item, 'permanentLoanType.name', '')), +}; + +export const MAX_HEIGHT = 500; +const CHUNK_SIZE = 40; + +const ItemsDialog = ({ + onClose, + open, + isLoading, + onRowClick = noop, + mutator, + skippedItemId, + title, + instanceId, +}) => { + const [areItemsBeingLoaded, setAreItemsBeingLoaded] = useState(false); + const [items, setItems] = useState([]); + const { formatMessage } = useIntl(); + + const fetchHoldings = () => { + const query = `instanceId==${instanceId}`; + mutator.holdings.reset(); + + 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); + } + + return data; + }; + + const fetchRequests = async (itemsList) => { + // Split the list of items into small chunks to create a short enough query string + // that we can avoid a "414 Request URI Too Long" response from Okapi. + const chunkedItems = chunk(itemsList, CHUNK_SIZE); + const data = []; + + for (const itemChunk of chunkedItems) { + let query = itemChunk.map(i => `itemId==${i.id}`).join(' or '); + const statusQuery = getStatusQuery(OPEN_REQUESTS_STATUSES); + + query = `(${query}) and (${statusQuery})")`; + + mutator.requests.reset(); + // eslint-disable-next-line no-await-in-loop + const result = await mutator.requests.GET({ params: { query, limit: MAX_RECORDS } }); + + data.push(...result); + } + + return data; + }; + + useLayoutEffect(() => { + const getItems = async () => { + setAreItemsBeingLoaded(true); + + const holdings = await fetchHoldings(); + let itemsList = await fetchItems(holdings); + + if (skippedItemId) { + itemsList = itemsList.filter(item => requestableItemStatuses.includes(item.status?.name)); + } + + const requests = await fetchRequests(itemsList); + const requestMap = countBy(requests, 'itemId'); + + itemsList = itemsList.map(item => ({ ...item, requestQueue: requestMap[item.id] || 0 })); + + setAreItemsBeingLoaded(false); + setItems(itemsList); + }; + + if (open && instanceId) { + getItems(); + } + + return () => setItems([]); + }, + // The deps react-hooks complains about here are the fetch* functions + // but both the suggestions (making them deps, moving them inside this + // function) cause test failures. I ... don't really understand the + // details of the problem, beyond the fact that it's obviously some kind + // of bad interaction between hooks and stripes-connect. + // + // eslint-disable-next-line react-hooks/exhaustive-deps + [instanceId, open]); + + const contentData = useMemo(() => { + let resultItems = items; + + if (skippedItemId) { + resultItems = resultItems + .filter(item => skippedItemId !== item.id); + } + + // items with status available must go first + resultItems.sort((a) => (a.status.name === itemStatuses.AVAILABLE ? -1 : 1)); + return resultItems.map(item => ({ + ...item, + status: { + ...item.status, + }, + })); + }, [items, skippedItemId]); + + const itemsAmount = contentData.length; + + return ( + + + + {isLoading || areItemsBeingLoaded + ? + : + } + + + + ); +}; + +ItemsDialog.manifest = { + holdings: { + type: 'okapi', + records: 'holdingsRecords', + path: 'holdings-storage/holdings', + accumulate: true, + fetch: false, + }, + items: { + type: 'okapi', + records: 'items', + path: 'inventory/items', + accumulate: true, + fetch: false, + }, + requests: { + type: 'okapi', + path: 'circulation/requests', + records: 'requests', + accumulate: true, + fetch: false, + }, +}; + +ItemsDialog.defaultProps = { + title: '', +}; + +ItemsDialog.propTypes = { + open: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + isLoading: PropTypes.bool, + title: PropTypes.string, + instanceId: PropTypes.string, + 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, + }).isRequired, + requests: PropTypes.shape({ + GET: PropTypes.func.isRequired, + reset: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, +}; + +export default stripesConnect(ItemsDialog); diff --git a/src/deprecated/components/ItemsDialog/ItemsDialog.test.js b/src/deprecated/components/ItemsDialog/ItemsDialog.test.js new file mode 100644 index 00000000..ea2f2083 --- /dev/null +++ b/src/deprecated/components/ItemsDialog/ItemsDialog.test.js @@ -0,0 +1,490 @@ +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import { + Modal, + MultiColumnList, + Pane, + Paneset, +} from '@folio/stripes/components'; + +import { + itemStatuses, + requestableItemStatuses, +} from '../../../constants'; +import { Loading } from '../../../components'; +import ItemsDialog, { + COLUMN_NAMES, + COLUMN_WIDTHS, + COLUMN_MAP, + formatter, + MAX_HEIGHT, +} from './ItemsDialog'; + +jest.mock('../../../components', () => ({ + Loading: jest.fn((props) => ( +
+ )), +})); + +const testIds = { + loading: 'loading', +}; +const labelIds = { + selectItem: 'ui-requests.items.selectItem', + instanceItems: 'ui-requests.items.instanceItems', + resultCount: 'ui-requests.resultCount', + instanceItemsNotFound: 'ui-requests.items.instanceItems.notFound', +}; + +describe('ItemsDialog', () => { + const onClose = jest.fn(); + const onRowClick = jest.fn(); + 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(() => { + resolve( + [{ + id: '1', + status: { + name: itemStatuses.IN_PROCESS, + }, + }, { + id: '2', + status: { + name: itemStatuses.AVAILABLE, + }, + }, { + id: '3', + status: { + name: itemStatuses.IN_TRANSIT, + }, + }] + ); + }); + }))), + reset: jest.fn(), + }, + requests: { + GET: jest.fn(() => (new Promise((resolve) => { + setTimeout(() => { + resolve( + [{ + id: '1', + itemId: '1', + }, { + id: '2', + itemId: '2', + }, { + id: '4', + itemId: '1', + }] + ); + }); + }))), + reset: jest.fn(), + }, + }; + const defaultTestProps = { + open: false, + onClose, + onRowClick, + instanceId: testInstanceId, + title: testTitle, + mutator: testMutator, + }; + + afterEach(() => { + Modal.mockClear(); + MultiColumnList.mockClear(); + Pane.mockClear(); + 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(); + testMutator.requests.reset.mockClear(); + }); + + describe('with default props', () => { + beforeEach(() => { + render(); + }); + + it('should render Modal', () => { + expect(Modal).toHaveBeenCalledWith( + expect.objectContaining({ + 'data-test-move-request-modal': true, + label: labelIds.selectItem, + open: false, + onClose, + dismissible: true, + }), {} + ); + }); + + it('should render Paneset', () => { + expect(Paneset).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: 'itemsDialog', + isRoot: true, + static: true, + }), {} + ); + }); + + it('should render Pane', () => { + expect(Pane).toHaveBeenLastCalledWith( + expect.objectContaining({ + paneTitle: labelIds.instanceItems, + paneSub: labelIds.resultCount, + defaultWidth: 'fill', + }), {} + ); + }); + + it('should not render Loading', () => { + expect(Loading).not.toHaveBeenCalled(); + }); + + it('should render MultiColumnList', () => { + expect(MultiColumnList).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: 'instance-items-list', + interactive: true, + contentData: [], + visibleColumns: COLUMN_NAMES, + columnMapping: COLUMN_MAP, + columnWidths: COLUMN_WIDTHS, + formatter, + maxHeight: MAX_HEIGHT, + isEmptyMessage: labelIds.instanceItemsNotFound, + onRowClick, + }), {} + ); + }); + }); + + describe('when open prop is true', () => { + beforeEach(() => { + render( + + ); + }); + + it('should render Modal', () => { + expect(Modal).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + 'data-test-move-request-modal': true, + label: labelIds.selectItem, + open: true, + onClose, + dismissible: true, + }), {} + ); + }); + + it('should render Loading when data is being loaded', () => { + const loading = screen.queryByTestId(testIds.loading); + + expect(loading).toBeInTheDocument(); + }); + + describe('when data is loaded', () => { + beforeEach(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + it('should hide Loading when data is loaded', () => { + const loading = screen.queryByTestId(testIds.loading); + + expect(loading).not.toBeInTheDocument(); + }); + + it('should render MultiColumnList when data is loaded', () => { + expect(MultiColumnList).toHaveBeenLastCalledWith( + { + id: 'instance-items-list', + interactive: true, + contentData: [{ + id: '2', + status: { + name: itemStatuses.AVAILABLE, + }, + requestQueue: 1, + }, { + id: '1', + status: { + name: itemStatuses.IN_PROCESS, + }, + requestQueue: 2, + }, { + id: '3', + status: { + name: itemStatuses.IN_TRANSIT, + }, + requestQueue: 0, + }], + visibleColumns: COLUMN_NAMES, + columnMapping: COLUMN_MAP, + columnWidths: COLUMN_WIDTHS, + formatter, + maxHeight: MAX_HEIGHT, + isEmptyMessage: labelIds.instanceItemsNotFound, + onRowClick, + }, {} + ); + }); + + describe('when "items" response contains non-requestable items', () => { + const getProcessedItem = (status) => ({ + id: status, + requestQueue: 0, + status: { + name: status, + }, + }); + const allItemStatuses = Object.values(itemStatuses); + const newMutator = { + ...testMutator, + items: { + GET: jest.fn(() => (new Promise((resolve) => { + setTimeout(() => { + resolve( + allItemStatuses.map(status => ({ + id: status, + status: { + name: status, + }, + })), + ); + }); + }))), + reset: jest.fn(), + }, + requests: { + GET: jest.fn(() => (new Promise((resolve) => { + setTimeout(() => { + resolve([]); + }); + }))), + reset: jest.fn(), + }, + }; + + describe('within "move request" action', () => { + beforeEach(async () => { + render( + + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + it('should show only items with requestable statuses', () => { + const requestableItems = requestableItemStatuses.map(getProcessedItem); + + expect(MultiColumnList).toHaveBeenLastCalledWith(expect.objectContaining({ + contentData: expect.arrayContaining(requestableItems), + }), {}); + }); + + it('should not show items with non-requestable statuses', () => { + const nonRequestableItems = allItemStatuses.map(status => { + if (requestableItemStatuses.includes(status)) { + return null; + } + + return getProcessedItem(status); + }); + + expect(MultiColumnList).toHaveBeenLastCalledWith(expect.objectContaining({ + contentData: expect.not.arrayContaining(nonRequestableItems), + }), {}); + }); + }); + + describe('within switching from instance-level to item-level request', () => { + beforeEach(async () => { + render( + + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + it('should show items with all possible statuses', () => { + const expectedResult = allItemStatuses.map(getProcessedItem); + + expect(MultiColumnList).toHaveBeenLastCalledWith(expect.objectContaining({ + contentData: expect.arrayContaining(expectedResult), + }), {}); + }); + }); + }); + }); + }); + + [ + true, + false, + ].forEach((isLoading) => { + describe(`when isLoading is ${isLoading}`, () => { + beforeEach(() => { + render( + + ); + }); + + if (isLoading) { + it('should render Loading', () => { + expect(Loading).toHaveBeenCalledTimes(1); + }); + + it('should not render MultiColumnList', () => { + expect(MultiColumnList).toHaveBeenCalledTimes(0); + }); + } else { + it('should not render Loading', () => { + expect(Loading).toHaveBeenCalledTimes(0); + }); + + it('should render MultiColumnList', () => { + expect(MultiColumnList).toHaveBeenLastCalledWith( + { + id: 'instance-items-list', + interactive: true, + contentData: [], + visibleColumns: COLUMN_NAMES, + columnMapping: COLUMN_MAP, + columnWidths: COLUMN_WIDTHS, + formatter, + maxHeight: MAX_HEIGHT, + isEmptyMessage: labelIds.instanceItemsNotFound, + onRowClick, + }, {} + ); + }); + } + }); + }); + + describe('formatter', () => { + const item = { + status: { + name: 'Aged to lost', + }, + effectiveLocation: { + name: 'effective location name', + }, + materialType: { + name: 'material type name', + }, + temporaryLoanType: { + name: 'temporary loan type name', + }, + permanentLoanType: { + name: 'permanent loan type name', + }, + }; + + describe('itemStatus', () => { + it('should return formatted message', () => { + expect(formatter.itemStatus(item).props.id).toEqual('ui-requests.item.status.agedToLost'); + }); + }); + + describe('location', () => { + it('should return effective location name', () => { + expect(formatter.location(item)).toEqual('effective location name'); + }); + + it('should return default value for effective location name', () => { + expect(formatter.location({ + ...item, + effectiveLocation: {}, + })).toEqual(''); + }); + }); + + describe('materialType', () => { + it('should return material type', () => { + expect(formatter.materialType(item)).toEqual('material type name'); + }); + }); + + describe('loanType', () => { + describe('with temporaryLoanType', () => { + it('should return temporary loan type name', () => { + expect(formatter.loanType(item)).toEqual('temporary loan type name'); + }); + + it('should return default value for temporary loan type name', () => { + expect(formatter.loanType({ + ...item, + temporaryLoanType: { + other: '', + }, + })).toEqual(''); + }); + }); + + describe('without temporaryLoanType', () => { + it('should return permanent loan type name', () => { + expect(formatter.loanType({ + ...item, + temporaryLoanType: false, + })).toEqual('permanent loan type name'); + }); + + it('should return default value for permanent loan type name', () => { + expect(formatter.loanType({ + ...item, + temporaryLoanType: false, + permanentLoanType: { + other: '', + }, + })).toEqual(''); + }); + }); + }); + }); +}); diff --git a/src/deprecated/components/MoveRequestManager/MoveRequestManager.js b/src/deprecated/components/MoveRequestManager/MoveRequestManager.js new file mode 100644 index 00000000..a8bb5205 --- /dev/null +++ b/src/deprecated/components/MoveRequestManager/MoveRequestManager.js @@ -0,0 +1,257 @@ +import { + get, + includes, +} from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +import { + stripesConnect, + stripesShape, +} from '@folio/stripes/core'; +import { + getHeaderWithCredentials, +} from '@folio/stripes/util'; + +import ItemsDialog from '../ItemsDialog/ItemsDialog'; +import ChooseRequestTypeDialog from '../../../ChooseRequestTypeDialog'; +import ErrorModal from '../../../components/ErrorModal'; +import { getRequestTypeOptions } from '../../../utils'; +import { REQUEST_OPERATIONS } from '../../../constants'; + +class MoveRequestManager extends React.Component { + static propTypes = { + onCancelMove: PropTypes.func, + onMove: PropTypes.func, + request: PropTypes.object.isRequired, + mutator: PropTypes.shape({ + move: PropTypes.shape({ + POST: PropTypes.func.isRequired, + }), + moveRequest: PropTypes.shape({ + POST: PropTypes.func.isRequired, + }), + }).isRequired, + stripes: stripesShape.isRequired, + }; + + static manifest = { + moveRequest: { + type: 'okapi', + POST: { + path: 'circulation/requests/!{request.id}/move', + }, + fetch: false, + throwErrors: false, + }, + }; + + constructor(props) { + super(props); + this.state = { + moveRequest: true, + moveInProgress: false, + requestTypes: [], + isRequestTypesLoading: false, + }; + + this.steps = [ + { + validate: this.shouldChooseRequestTypeDialogBeShown, + exec: () => this.setState({ + chooseRequestType: true, + moveRequest: false, + }), + }, + ]; + } + + execSteps = (start) => { + for (let i = start; i < this.steps.length; i++) { + const step = this.steps[i]; + if (step.validate()) { + return step.exec(); + } + } + + return this.moveRequest(); + } + + shouldChooseRequestTypeDialogBeShown = () => { + const { requestTypes } = this.state; + const { + request: { + requestType, + }, + } = this.props; + + return !includes(requestTypes, requestType); + } + + confirmChoosingRequestType = (selectedRequestType) => { + this.setState({ + selectedRequestType, + }, () => this.execSteps(1)); + } + + moveRequest = async () => { + const { + selectedItem, + selectedRequestType, + } = this.state; + const { + mutator: { + moveRequest: { POST }, + }, + request, + } = this.props; + const requestType = selectedRequestType || request.requestType; + const destinationItemId = selectedItem.id; + const data = { + destinationItemId, + requestType, + }; + + this.setState({ moveInProgress: true }); + + try { + const movedRequest = await POST(data); + this.props.onMove(movedRequest); + + this.setState({ + chooseRequestType: false, + }); + } catch (resp) { + this.processError(resp); + } finally { + this.setState({ moveInProgress: false }); + } + } + + processError(resp) { + const contentType = resp.headers.get('Content-Type') || ''; + if (contentType.startsWith('application/json')) { + return resp.json().then(error => this.handleError(get(error, 'errors[0].message'))); + } else { + return resp.text().then(error => this.handleError(error)); + } + } + + handleError(errorMessage) { + this.setState({ + chooseRequestType: false, + errorMessage, + }); + } + + onItemSelected = (selectedItem) => { + const { + request, + stripes, + } = this.props; + const httpHeadersOptions = { + ...getHeaderWithCredentials({ + tenant: stripes.okapi.tenant, + 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}`; + + this.setState({ + isRequestTypesLoading: true, + requestTypes: [], + }); + + fetch(url, httpHeadersOptions) + .then(res => { + if (res.ok) { + return res.json(); + } + + return Promise.reject(res); + }) + .then((res) => { + this.setState({ + requestTypes: Object.keys(res), + selectedItem, + }, () => this.execSteps(0)); + }) + .catch(() => { + this.execSteps(0); + }) + .finally(() => { + this.setState({ + isRequestTypesLoading: false, + }); + }); + } + + cancelMoveRequest = () => { + this.setState({ + moveRequest: true, + chooseRequestType: false, + selectedRequestType: '', + }); + } + + closeErrorMessage = () => { + const { requestTypes } = this.state; + const state = { errorMessage: null }; + + if (requestTypes && requestTypes.length > 1) { + state.chooseRequestType = true; + } else { + state.moveRequest = true; + } + + this.setState(state); + } + + render() { + const { + onCancelMove, + request, + } = this.props; + const { + chooseRequestType, + errorMessage, + moveRequest, + moveInProgress, + requestTypes, + isRequestTypesLoading, + } = this.state; + const isLoading = moveInProgress || isRequestTypesLoading; + + return ( + <> + this.onItemSelected(item)} + /> + {chooseRequestType && + } + {errorMessage && + } + errorMessage={errorMessage} + /> } + + ); + } +} + +export default stripesConnect(MoveRequestManager); diff --git a/src/deprecated/components/MoveRequestManager/MoveRequestManager.test.js b/src/deprecated/components/MoveRequestManager/MoveRequestManager.test.js new file mode 100644 index 00000000..1a4f0b17 --- /dev/null +++ b/src/deprecated/components/MoveRequestManager/MoveRequestManager.test.js @@ -0,0 +1,404 @@ +import { + render, + screen, + fireEvent, + cleanup, + waitFor, +} from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + +import MoveRequestManager from './MoveRequestManager'; +import ItemsDialog from '../ItemsDialog/ItemsDialog'; +import ChooseRequestTypeDialog from '../../../ChooseRequestTypeDialog'; +import ErrorModal from '../../../components/ErrorModal'; +import { + REQUEST_OPERATIONS, + requestTypeOptionMap, + requestTypesMap, +} from '../../../constants'; + +const labelIds = { + requestNotAllowed: 'ui-requests.requestNotAllowed', +}; +const testIds = { + rowButton: 'rowButton', + confirmButton: 'confirmButton', + cancelButton: 'cancelButton', + chooseRequestTypeDialog: 'chooseRequestTypeDialog', + errorModal: 'errorModal', + closeErrorModalButton: 'closeErrorModalButton', +}; +const movedRequest = {}; +const basicProps = { + onCancelMove: jest.fn(), + onMove: jest.fn(), + request: { + requestType: requestTypesMap.PAGE, + instanceId: 'instanceId', + itemId: 'itemId', + instance: { + title: 'instanceTitle', + }, + id: 'requestId', + }, + mutator: { + move: { + POST: jest.fn(), + }, + moveRequest: { + POST: jest.fn(async () => movedRequest), + }, + }, + stripes: { + okapi: { + url: 'okapiUrl', + tenant: 'okapiTenant', + }, + store: { + getState: () => ({ + okapi: { + token: 'okapiToken', + }, + }), + }, + }, +}; +const selectedItem = { + id: 'selectedItemId', + status: { + name: 'Checked out', + }, +}; + +jest.mock('../ItemsDialog/ItemsDialog', () => jest.fn(({ + children, + onRowClick, +}) => { + return ( +
+ + {children} +
+ ); +})); +jest.mock('../../../ChooseRequestTypeDialog', () => jest.fn(({ + onConfirm, + onCancel +}) => ( +
+ + +
+))); +jest.mock('../../../components/ErrorModal', () => jest.fn(({ + label, + onClose, +}) => ( +
+

{label}

+ +
+))); + +describe('MoveRequestManager', () => { + beforeEach(() => { + global.fetch = jest.fn(() => Promise.resolve({ + json: () => ({ + [requestTypesMap.HOLD]: ['id'], + }) + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + cleanup(); + }); + + describe('Initial rendering', () => { + beforeEach(() => { + render( + + ); + }); + + it('should trigger "ItemsDialog" with correct props', () => { + const expectedProps = { + open: true, + instanceId: basicProps.request.instanceId, + title: basicProps.request.instance.title, + isLoading: false, + onClose: basicProps.onCancelMove, + skippedItemId: basicProps.request.itemId, + onRowClick: expect.any(Function), + }; + + expect(ItemsDialog).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + + it('should not trigger "ChooseRequestTypeDialog"', () => { + expect(ChooseRequestTypeDialog).not.toHaveBeenCalled(); + }); + + it('should not trigger "ErrorModal"', () => { + expect(ErrorModal).not.toHaveBeenCalled(); + }); + }); + + describe('Request type dialog', () => { + beforeEach(() => { + global.fetch = jest.fn(() => Promise.resolve({ + ok: true, + json: () => ({ + [requestTypesMap.HOLD]: ['id'], + }) + })); + + render( + + ); + + const rowButton = screen.getByTestId(testIds.rowButton); + + fireEvent.click(rowButton); + }); + + 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}`; + + expect(global.fetch).toHaveBeenCalledWith(expectedUrl, {}); + }); + + it('should trigger "ChooseRequestTypeDialog" with correct props', async () => { + const expectedProps = { + open: true, + 'data-test-choose-request-type-modal': true, + onConfirm: expect.any(Function), + onCancel: expect.any(Function), + isLoading: false, + requestTypes: [ + { + id: requestTypeOptionMap[requestTypesMap.HOLD], + value: requestTypesMap.HOLD, + } + ], + }; + + await waitFor(() => expect(ChooseRequestTypeDialog).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {})); + }); + + it('should hide request type dialog after clicking on "Cancel" button', async () => { + await waitFor(() => { + const cancelButton = screen.getByTestId(testIds.cancelButton); + const chooseRequestTypeDialog = screen.getByTestId(testIds.chooseRequestTypeDialog); + + fireEvent.click(cancelButton); + + expect(chooseRequestTypeDialog).not.toBeInTheDocument(); + }); + }); + + it('should trigger "moveRequest.POST" after clicking on "Confirm" button', async () => { + await waitFor(() => { + const confirmButton = screen.getByTestId(testIds.confirmButton); + + fireEvent.click(confirmButton); + + expect(basicProps.mutator.moveRequest.POST).toHaveBeenCalled(); + }); + }); + + it('should trigger "onMove" with correct argument after clicking on "Confirm" button', async () => { + await waitFor(async () => { + const confirmButton = screen.getByTestId(testIds.confirmButton); + + fireEvent.click(confirmButton); + + await waitFor(() => { + expect(basicProps.onMove).toHaveBeenCalledWith(movedRequest); + }); + }); + }); + }); + + describe('Error modal', () => { + describe('When response content type is "application/json"', () => { + const error = { + errors: [ + { + message: 'Error message', + } + ] + }; + const get = jest.fn(() => 'application/json'); + const json = jest.fn(() => new Promise(res => res(error))); + + beforeEach(async () => { + global.fetch = jest.fn(() => Promise.resolve({ + ok: true, + json: () => ({ + [requestTypesMap.HOLD]: ['holdId'], + [requestTypesMap.RECALL]: ['recallId'], + }) + })); + + const props = { + ...basicProps, + mutator: { + ...basicProps.mutator, + moveRequest: { + POST: () => { + const errorToThrow = new Error('message'); + + errorToThrow.headers = { + get, + }; + errorToThrow.json = json; + + throw errorToThrow; + }, + }, + }, + }; + + render( + + ); + + const rowButton = screen.getByTestId(testIds.rowButton); + + await userEvent.click(rowButton); + + const confirmButton = screen.getByTestId(testIds.confirmButton); + + await userEvent.click(confirmButton); + }); + + it('should trigger "ErrorModal" with correct props', async () => { + const expectedProps = { + onClose: expect.any(Function), + errorMessage: error.errors[0].message, + }; + + await waitFor(() => expect(ErrorModal).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {})); + }); + + it('should render "ErrorModal" label', async () => { + const requestNotAllowedLabel = screen.getByText(labelIds.requestNotAllowed); + + await waitFor(() => expect(requestNotAllowedLabel).toBeInTheDocument()); + }); + + it('should not render "ChooseRequestTypeDialog"', async () => { + const chooseRequestTypeDialog = screen.queryByTestId(testIds.chooseRequestTypeDialog); + + await waitFor(() => expect(chooseRequestTypeDialog).not.toBeInTheDocument()); + }); + + it('should hide "ErrorModal"', async () => { + const errorModal = screen.getByTestId(testIds.errorModal); + const closeErrorModalButton = screen.getByTestId(testIds.closeErrorModalButton); + + fireEvent.click(closeErrorModalButton); + + await waitFor(() => expect(errorModal).not.toBeInTheDocument()); + }); + + it('should render "ChooseRequestTypeDialog" after closing error modal', async () => { + const closeErrorModalButton = screen.getByTestId(testIds.closeErrorModalButton); + + fireEvent.click(closeErrorModalButton); + + const chooseRequestTypeDialog = screen.queryByTestId(testIds.chooseRequestTypeDialog); + + await waitFor(() => expect(chooseRequestTypeDialog).toBeInTheDocument()); + }); + }); + + describe('When response content type is not "application/json"', () => { + const error = 'Test error'; + const get = jest.fn(() => ''); + const text = jest.fn(() => new Promise(res => res(error))); + + beforeEach(async () => { + const props = { + ...basicProps, + mutator: { + ...basicProps.mutator, + moveRequest: { + POST: () => { + const errorToThrow = new Error('message'); + + errorToThrow.text = text; + errorToThrow.headers = { + get, + }; + + throw errorToThrow; + }, + }, + }, + }; + + global.fetch = jest.fn(() => Promise.resolve({ + ok: true, + json: () => ({ + [requestTypesMap.HOLD]: ['holdId'], + [requestTypesMap.RECALL]: ['recallId'], + }) + })); + + render( + + ); + + const rowButton = screen.getByTestId(testIds.rowButton); + + await waitFor(() => { + fireEvent.click(rowButton); + + const confirmButton = screen.getByTestId(testIds.confirmButton); + + fireEvent.click(confirmButton); + }); + }); + + it('should trigger "ErrorModal" with correct props', async () => { + const expectedProps = { + onClose: expect.any(Function), + errorMessage: error, + }; + + await waitFor(() => expect(ErrorModal).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {})); + }); + }); + }); +}); diff --git a/src/deprecated/components/RequestForm/RequestForm.css b/src/deprecated/components/RequestForm/RequestForm.css new file mode 100644 index 00000000..9c5c8b4a --- /dev/null +++ b/src/deprecated/components/RequestForm/RequestForm.css @@ -0,0 +1,17 @@ +.requestForm { + height: 100%; + width: 100%; + overflow: auto; +} + +.footerContent { + max-width: 50em; + display: flex; + justify-content: space-between; + width: 100%; +} + +.tlrCheckbox { + margin-bottom: 0.5em; + user-select: none; +} diff --git a/src/deprecated/components/RequestForm/RequestForm.js b/src/deprecated/components/RequestForm/RequestForm.js new file mode 100644 index 00000000..ba0387d3 --- /dev/null +++ b/src/deprecated/components/RequestForm/RequestForm.js @@ -0,0 +1,1412 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Field, +} from 'react-final-form'; +import { + FormattedMessage, +} from 'react-intl'; +import { parse } from 'query-string'; + +import { + sortBy, + find, + get, + isEqual, + isEmpty, + keyBy, + defer, + pick, + isBoolean, + isNil, +} from 'lodash'; + +import { + Accordion, + AccordionSet, + Button, + Col, + Pane, + PaneFooter, + PaneHeaderIconButton, + PaneMenu, + Paneset, + Row, + Checkbox, + AccordionStatus, +} from '@folio/stripes/components'; +import stripesFinalForm from '@folio/stripes/final-form'; + +import RequestFormShortcutsWrapper from '../../../components/RequestFormShortcutsWrapper'; +import CancelRequestDialog from '../../../CancelRequestDialog'; +import PatronBlockModal from '../../../PatronBlockModal'; +import { + ErrorModal, + RequestInformation, + RequesterInformation, + FulfilmentPreference, +} from '../../../components'; +import ItemInformation from '../ItemInformation/ItemInformation'; +import InstanceInformation from '../InstanceInformation/InstanceInformation'; +import ItemsDialog from '../ItemsDialog/ItemsDialog'; +import { + iconTypes, + fulfillmentTypeMap, + createModes, + REQUEST_LEVEL_TYPES, + RESOURCE_KEYS, + REQUEST_FORM_FIELD_NAMES, + DEFAULT_REQUEST_TYPE_VALUE, + requestTypeOptionMap, + REQUEST_LAYERS, + REQUEST_OPERATIONS, +} from '../../../constants'; +import { RESOURCE_TYPES } from '../../constants'; +import { + handleKeyCommand, + toUserAddress, + getPatronGroup, + isDelivery, + parseErrorMessage, + getFulfillmentTypeOptions, + getDefaultRequestPreferences, + getFulfillmentPreference, + isDeliverySelected, + getSelectedAddressTypeId, + getProxy, + isSubmittingButtonDisabled, + isFormEditing, + resetFieldState, + getRequester, +} from '../../../utils'; +import { getTlrSettings } from '../../utils'; + +import css from './RequestForm.css'; + +export const ID_TYPE_MAP = { + ITEM_ID: 'itemId', + INSTANCE_ID: 'instanceId', +}; +export const getResourceTypeId = (isTitleLevelRequest) => (isTitleLevelRequest ? ID_TYPE_MAP.INSTANCE_ID : ID_TYPE_MAP.ITEM_ID); +export const isTLR = (createTitleLevelRequest, requestLevel) => (createTitleLevelRequest || requestLevel === REQUEST_LEVEL_TYPES.TITLE); +export const getRequestInformation = (values, selectedInstance, selectedItem, request) => { + const isTitleLevelRequest = isTLR(values.createTitleLevelRequest, request?.requestLevel); + const selectedResource = isTitleLevelRequest ? selectedInstance : selectedItem; + + return { + isTitleLevelRequest, + selectedResource, + }; +}; + +class RequestForm extends React.Component { + static propTypes = { + stripes: PropTypes.shape({ + connect: PropTypes.func.isRequired, + store: PropTypes.shape({ + getState: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, + errorMessage: PropTypes.string, + handleSubmit: PropTypes.func.isRequired, + findResource: PropTypes.func.isRequired, + request: PropTypes.object, + metadataDisplay: PropTypes.func, + initialValues: PropTypes.object, + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired, + search: PropTypes.string, + }).isRequired, + onCancel: PropTypes.func.isRequired, + onCancelRequest: PropTypes.func, + pristine: PropTypes.bool, + resources: PropTypes.shape({ + query: PropTypes.object, + }), + submitting: PropTypes.bool, + toggleModal: PropTypes.func, + optionLists: PropTypes.shape({ + addressTypes: PropTypes.arrayOf(PropTypes.object), + fulfillmentTypes: PropTypes.arrayOf(PropTypes.object), + servicePoints: PropTypes.arrayOf(PropTypes.object), + }), + patronGroups: PropTypes.arrayOf(PropTypes.object), + parentResources: PropTypes.object, + history: PropTypes.shape({ + push: PropTypes.func, + }), + intl: PropTypes.object, + onChangePatron: PropTypes.func, + query: PropTypes.object, + selectedItem: PropTypes.object, + selectedInstance: PropTypes.object, + selectedUser: PropTypes.object, + 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({ + proxy: PropTypes.shape({ + reset: PropTypes.func.isRequired, + GET: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, + isTlrEnabledOnEditPage: PropTypes.bool, + onGetAutomatedPatronBlocks: PropTypes.func.isRequired, + onGetPatronManualBlocks: PropTypes.func.isRequired, + onSetSelectedItem: PropTypes.func.isRequired, + onSetSelectedUser: PropTypes.func.isRequired, + onSetSelectedInstance: PropTypes.func.isRequired, + onSetBlocked: PropTypes.func.isRequired, + onSetIsPatronBlocksOverridden: PropTypes.func.isRequired, + onSetInstanceId: PropTypes.func.isRequired, + }; + + static defaultProps = { + request: null, + metadataDisplay: () => { }, + optionLists: {}, + pristine: true, + submitting: false, + isTlrEnabledOnEditPage: false, + }; + + constructor(props) { + super(props); + + const { + request, + initialValues, + } = props; + const { + loan, + } = (request || {}); + + const { titleLevelRequestsFeatureEnabled } = this.getTlrSettings(); + + this.state = { + proxy: null, + selectedLoan: loan, + ...getDefaultRequestPreferences(request, initialValues), + isAwaitingForProxySelection: false, + titleLevelRequestsFeatureEnabled, + isItemOrInstanceLoading: false, + isItemsDialogOpen: false, + isItemIdRequest: this.isItemIdProvided(), + requestTypes: {}, + isRequestTypesReceived: false, + isRequestTypeLoading: false, + isRequestTypesForDuplicate: false, + isRequestTypesForEditing: false, + }; + + this.connectedCancelRequestDialog = props.stripes.connect(CancelRequestDialog); + this.onChangeAddress = this.onChangeAddress.bind(this); + this.onSelectProxy = this.onSelectProxy.bind(this); + this.onClose = this.onClose.bind(this); + this.accordionStatusRef = React.createRef(); + } + + componentDidMount() { + const { + query: { + userId, + }, + } = this.props; + + if (this.props.query.userBarcode) { + this.findUser(RESOURCE_KEYS.barcode, this.props.query.userBarcode); + } else if (userId) { + this.findUser(RESOURCE_KEYS.id, userId); + } + + if (this.props.query.itemBarcode) { + this.findItem(RESOURCE_KEYS.barcode, this.props.query.itemBarcode); + } + + if (this.props.query.itemId) { + this.findItem(RESOURCE_KEYS.id, this.props.query.itemId); + } + + if (this.props.query.instanceId && !this.props.query.itemBarcode && !this.props.query.itemId) { + this.findInstance(this.props.query.instanceId); + } + + if (isFormEditing(this.props.request)) { + this.findRequestPreferences(this.props.initialValues.requesterId); + } + + this.setTlrCheckboxInitialState(); + } + + componentDidUpdate(prevProps) { + const { + isRequestTypesForDuplicate, + isRequestTypesForEditing, + } = this.state; + const { + initialValues, + request, + values, + parentResources, + query, + selectedItem, + selectedUser, + selectedInstance, + onGetAutomatedPatronBlocks, + onGetPatronManualBlocks, + onSetBlocked, + onSetSelectedItem, + onSetSelectedUser, + } = this.props; + + const { + initialValues: prevInitialValues, + request: prevRequest, + parentResources: prevParentResources, + query: prevQuery, + } = prevProps; + + const prevBlocks = onGetPatronManualBlocks(prevParentResources); + const blocks = onGetPatronManualBlocks(parentResources); + const prevAutomatedPatronBlocks = onGetAutomatedPatronBlocks(prevParentResources); + const automatedPatronBlocks = onGetAutomatedPatronBlocks(parentResources); + const { item } = initialValues; + + if ( + (initialValues && + initialValues.fulfillmentPreference && + prevInitialValues && + !prevInitialValues.fulfillmentPreference) || + !isEqual(request, prevRequest) + ) { + onSetSelectedItem(request.item); + onSetSelectedUser(request.requester); + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + selectedAddressTypeId: initialValues.deliveryAddressTypeId, + deliverySelected: isDelivery(initialValues), + selectedLoan: request.loan, + }); + } + + // When in duplicate mode there are cases when selectedItem from state + // is missing or not set. In this case just set it to initial item. + if (query?.mode === createModes.DUPLICATE && + item && !selectedItem) { + onSetSelectedItem(item); + this.triggerItemBarcodeValidation(); + } + + if (query?.mode === createModes.DUPLICATE && + (selectedItem?.id || selectedInstance?.id) && + selectedUser?.id && + !isRequestTypesForDuplicate + ) { + this.setState({ + isRequestTypesForDuplicate: true, + }); + this.getAvailableRequestTypes(selectedUser); + } + + if (query?.layer === REQUEST_LAYERS.EDIT && + !isRequestTypesForEditing + ) { + this.setState({ + isRequestTypesForEditing: true, + }); + + const isTitleLevelRequest = isTLR(values.createTitleLevelRequest, request.requestLevel); + const resourceTypeId = getResourceTypeId(isTitleLevelRequest); + const resourceId = isTitleLevelRequest ? request.instanceId : request.itemId; + + this.findRequestTypes(resourceId, request.requester.id || request.requesterId, resourceTypeId); + } + + if (prevQuery.userBarcode !== query.userBarcode) { + this.findUser(RESOURCE_KEYS.barcode, query.userBarcode); + } + + if (prevQuery.userId !== query.userId) { + this.findUser(RESOURCE_KEYS.id, query.userId); + } + + if (prevQuery.itemBarcode !== query.itemBarcode) { + this.findItem(RESOURCE_KEYS.barcode, query.itemBarcode); + } + + if (prevQuery.itemId !== query.itemId) { + this.findItem(RESOURCE_KEYS.id, query.itemId); + } + + if (prevQuery.instanceId !== query.instanceId) { + this.findInstance(query.instanceId); + this.setTlrCheckboxInitialState(); + } + + if (!isEqual(blocks, prevBlocks) && blocks.length > 0) { + const user = selectedUser || {}; + if (user.id === blocks[0].userId) { + onSetBlocked(true); + } + } + + if (!isEqual(prevAutomatedPatronBlocks, automatedPatronBlocks) && !isEmpty(automatedPatronBlocks)) { + if (selectedUser.id) { + onSetBlocked(true); + } + } + + if (prevParentResources?.configs?.records[0]?.value !== parentResources?.configs?.records[0]?.value) { + const { + titleLevelRequestsFeatureEnabled, + } = this.getTlrSettings(); + + // eslint-disable-next-line react/no-did-update-set-state + this.setState( + { + titleLevelRequestsFeatureEnabled, + }, + this.setTlrCheckboxInitialState(), + ); + } + } + + isItemIdProvided = () => { + const { + query, + location, + } = this.props; + const itemId = query?.itemId || parse(location.search)?.itemId; + + return Boolean(itemId); + } + + getTlrSettings() { + return getTlrSettings(this.props.parentResources?.configs?.records[0]?.value); + } + + setTlrCheckboxInitialState() { + const { + form, + } = this.props; + + if (this.state.titleLevelRequestsFeatureEnabled === false) { + form.change(REQUEST_FORM_FIELD_NAMES.CREATE_TLR, false); + return; + } + + if (this.props.query.itemId || this.props.query.itemBarcode) { + form.change(REQUEST_FORM_FIELD_NAMES.CREATE_TLR, false); + } else if (this.props.query.instanceId) { + form.change(REQUEST_FORM_FIELD_NAMES.CREATE_TLR, true); + } + } + + onClose() { + this.props.toggleModal(); + } + + changeDeliveryAddress = (deliverySelected, selectedAddressTypeId) => { + this.setState({ + deliverySelected, + selectedAddressTypeId, + }, () => { + this.updateRequestPreferencesFields(); + }); + } + + onChangeAddress(e) { + const { form } = this.props; + const selectedAddressTypeId = e.target.value; + + form.change(REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, selectedAddressTypeId); + this.setState({ + selectedAddressTypeId, + }); + } + + getAvailableRequestTypes = (user) => { + const { + selectedItem, + selectedInstance, + request, + values, + } = this.props; + const { + selectedResource, + isTitleLevelRequest, + } = getRequestInformation(values, selectedInstance, selectedItem, request); + + if (selectedResource?.id && user?.id) { + const resourceTypeId = getResourceTypeId(isTitleLevelRequest); + + this.findRequestTypes(selectedResource.id, user.id, resourceTypeId); + } + } + + // Executed when a user is selected from the proxy dialog, + // regardless of whether the selection is "self" or an actual proxy + onSelectProxy(proxy) { + const { + form, + selectedUser, + } = this.props; + + if (selectedUser.id === proxy.id) { + this.setState({ + proxy: selectedUser, + }); + } else { + this.setState({ + proxy, + requestTypes: {}, + isRequestTypesReceived: false, + }); + form.change(REQUEST_FORM_FIELD_NAMES.REQUESTER_ID, proxy.id); + form.change(REQUEST_FORM_FIELD_NAMES.PROXY_USER_ID, selectedUser.id); + this.findRequestPreferences(proxy.id); + this.getAvailableRequestTypes(proxy); + } + + this.setState({ isAwaitingForProxySelection: false }); + } + + async hasProxies(user) { + if (!user) { + this.setState({ isAwaitingForProxySelection: false }); + + return null; + } + + const { parentMutator: mutator } = this.props; + const query = `query=(proxyUserId==${user.id})`; + + mutator.proxy.reset(); + + const userProxies = await mutator.proxy.GET({ params: { query } }); + + if (userProxies.length) { + this.setState({ isAwaitingForProxySelection: true }); + } else { + this.setState({ isAwaitingForProxySelection: false }); + } + + return user; + } + + shouldSetBlocked = (blocks, selectedUser) => { + return blocks.length && blocks[0].userId === selectedUser.id; + } + + findUser = (fieldName, value, isValidation = false) => { + const { + form, + findResource, + parentResources, + onChangePatron, + onGetPatronManualBlocks, + onGetAutomatedPatronBlocks, + onSetIsPatronBlocksOverridden, + onSetSelectedUser, + onSetBlocked, + } = this.props; + + this.setState({ + isUserLoading: true, + }); + + if (isValidation) { + return findResource(RESOURCE_TYPES.USER, value, fieldName) + .then((result) => { + return result.totalRecords; + }) + .finally(() => { + this.setState({ isUserLoading: false }); + }); + } else { + this.setState({ + proxy: null, + requestTypes: {}, + isRequestTypesReceived: false, + }); + form.change(REQUEST_FORM_FIELD_NAMES.PICKUP_SERVICE_POINT_ID, undefined); + form.change(REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, undefined); + form.change(REQUEST_FORM_FIELD_NAMES.PROXY_USER_ID, undefined); + + return findResource(RESOURCE_TYPES.USER, value, fieldName) + .then((result) => { + this.setState({ isAwaitingForProxySelection: true }); + + if (result.totalRecords === 1) { + const blocks = onGetPatronManualBlocks(parentResources); + const automatedPatronBlocks = onGetAutomatedPatronBlocks(parentResources); + const isAutomatedPatronBlocksRequestInPendingState = parentResources.automatedPatronBlocks.isPending; + const selectedUser = result.users[0]; + onChangePatron(selectedUser); + form.change(REQUEST_FORM_FIELD_NAMES.REQUESTER_ID, selectedUser.id); + form.change(REQUEST_FORM_FIELD_NAMES.REQUESTER, selectedUser); + onSetSelectedUser(selectedUser); + + if (fieldName === RESOURCE_KEYS.id) { + this.triggerUserBarcodeValidation(); + } + + this.findRequestPreferences(selectedUser.id); + + if (this.shouldSetBlocked(blocks, selectedUser) || (!isEmpty(automatedPatronBlocks) && !isAutomatedPatronBlocksRequestInPendingState)) { + onSetBlocked(true); + onSetIsPatronBlocksOverridden(false); + } + + return selectedUser; + } + + return null; + }) + .then(user => { + this.getAvailableRequestTypes(user); + + return user; + }) + .then(user => this.hasProxies(user)) + .finally(() => { + this.setState({ isUserLoading: false }); + }); + } + } + + async findRequestPreferences(userId) { + const { + findResource, + form, + request, + initialValues, + } = this.props; + + try { + const { requestPreferences } = await findResource('requestPreferences', userId, 'userId'); + const preferences = requestPreferences[0]; + + const defaultPreferences = getDefaultRequestPreferences(request, initialValues); + const requestPreference = { + ...defaultPreferences, + ...pick(preferences, ['defaultDeliveryAddressTypeId', 'defaultServicePointId']), + requestPreferencesLoaded: true, + }; + + // when in edit mode (editing existing request) and defaultServicePointId is present (it was + // set during creation) just keep it instead of choosing the preferred one. + // https://issues.folio.org/browse/UIREQ-544 + if (isFormEditing(request) && defaultPreferences.defaultServicePointId) { + requestPreference.defaultServicePointId = defaultPreferences.defaultServicePointId; + } + + const deliveryIsPredefined = get(preferences, 'delivery'); + + if (isBoolean(deliveryIsPredefined)) { + requestPreference.hasDelivery = deliveryIsPredefined; + } + + const fulfillmentPreference = getFulfillmentPreference(preferences, initialValues); + const deliverySelected = isDeliverySelected(fulfillmentPreference); + + const selectedAddress = requestPreference.selectedAddressTypeId || requestPreference.defaultDeliveryAddressTypeId; + + const selectedAddressTypeId = getSelectedAddressTypeId(deliverySelected, selectedAddress); + + this.setState({ + ...requestPreference, + deliverySelected, + selectedAddressTypeId, + }, () => { + form.change(REQUEST_FORM_FIELD_NAMES.FULFILLMENT_PREFERENCE, fulfillmentPreference); + + this.updateRequestPreferencesFields(); + }); + } catch (e) { + this.setState({ + ...getDefaultRequestPreferences(request, initialValues), + deliverySelected: false, + }, () => { + form.change(REQUEST_FORM_FIELD_NAMES.FULFILLMENT_PREFERENCE, fulfillmentTypeMap.HOLD_SHELF); + }); + } + } + + updateRequestPreferencesFields = () => { + const { + defaultDeliveryAddressTypeId, + defaultServicePointId, + deliverySelected, + selectedAddressTypeId, + } = this.state; + const { + initialValues: { + requesterId, + }, + form, + selectedUser, + } = this.props; + + if (deliverySelected) { + const deliveryAddressTypeId = selectedAddressTypeId || defaultDeliveryAddressTypeId; + + form.change(REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, deliveryAddressTypeId); + form.change(REQUEST_FORM_FIELD_NAMES.PICKUP_SERVICE_POINT_ID, ''); + } else { + // Only change pickupServicePointId to defaultServicePointId + // if selected user has changed (by choosing a different user manually) + // or if the request form is not in a DUPLICATE mode. + // In DUPLICATE mode the pickupServicePointId from a duplicated request record will be used instead. + if (requesterId !== selectedUser?.id || this.props?.query?.mode !== createModes.DUPLICATE) { + form.change(REQUEST_FORM_FIELD_NAMES.PICKUP_SERVICE_POINT_ID, defaultServicePointId); + } + form.change(REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, ''); + } + } + + findRequestTypes = (resourceId, requesterId, resourceType) => { + const { + findResource, + form, + request, + } = this.props; + const isEditForm = isFormEditing(request); + let requestParams; + + if (isEditForm) { + requestParams = { + operation: REQUEST_OPERATIONS.REPLACE, + requestId: request.id, + }; + } else { + requestParams = { + operation: REQUEST_OPERATIONS.CREATE, + [resourceType]: resourceId, + requesterId, + }; + form.change(REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE, DEFAULT_REQUEST_TYPE_VALUE); + } + + this.setState({ + isRequestTypeLoading: true, + }); + + findResource(RESOURCE_TYPES.REQUEST_TYPES, requestParams) + .then(requestTypes => { + if (!isEmpty(requestTypes)) { + this.setState({ + requestTypes, + isRequestTypesReceived: true, + }, this.triggerRequestTypeValidation); + } else { + this.setState({ + isRequestTypesReceived: true, + }, this.triggerRequestTypeValidation); + } + }) + .finally(() => { + this.setState({ + isRequestTypeLoading: false, + }); + }); + } + + findItemRelatedResources(item) { + const { + findResource, + onSetInstanceId, + } = this.props; + 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, + }); + + return item; + }); + } + + setItemIdRequest = (key, isBarcodeRequired) => { + const { isItemIdRequest } = this.state; + + if (key === RESOURCE_KEYS.id && !isBarcodeRequired) { + this.setState({ + isItemIdRequest: true, + }); + } else if (key === RESOURCE_KEYS.barcode && isItemIdRequest) { + this.setState({ + isItemIdRequest: false, + }); + } + }; + + findItem = (key, value, isValidation = false, isBarcodeRequired = false) => { + const { + findResource, + form, + onSetSelectedItem, + selectedUser, + } = this.props; + const { proxy } = this.state; + + this.setState({ + isItemOrInstanceLoading: true, + }); + + if (isValidation) { + return findResource(RESOURCE_TYPES.ITEM, value, key) + .then((result) => { + return result.totalRecords; + }) + .finally(() => { + this.setState({ isItemOrInstanceLoading: false }); + }); + } else { + this.setState({ + requestTypes: {}, + isRequestTypesReceived: false, + }); + + return findResource(RESOURCE_TYPES.ITEM, value, key) + .then((result) => { + this.setItemIdRequest(key, isBarcodeRequired); + + if (!result || result.totalRecords === 0) { + this.setState({ + isItemOrInstanceLoading: false, + }); + + return null; + } + + const item = result.items[0]; + + form.change(REQUEST_FORM_FIELD_NAMES.ITEM_ID, item.id); + form.change(REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, item.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); + this.setState({ + isItemOrInstanceLoading: false, + }); + + return item; + }) + .then(item => { + if (item && selectedUser?.id) { + const requester = getRequester(proxy, selectedUser); + this.findRequestTypes(item.id, requester.id, ID_TYPE_MAP.ITEM_ID); + } + + return item; + }) + .then(item => this.findItemRelatedResources(item)); + } + } + + findInstanceRelatedResources(instance) { + if (!instance) { + return null; + } + + const { findResource } = this.props; + + return findResource('requestsForInstance', instance.id) + .then((result) => { + const instanceRequestCount = result.requests.filter(r => r.requestLevel === REQUEST_LEVEL_TYPES.TITLE).length || 0; + + this.setState({ instanceRequestCount }); + + return instance; + }); + } + + findInstance = async (instanceId, holdingsRecordId, isValidation = false) => { + const { + findResource, + form, + onSetSelectedInstance, + selectedUser, + } = this.props; + const { proxy } = this.state; + + this.setState({ + 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) + .then((result) => { + return result.totalRecords; + }) + .finally(() => { + this.setState({ isItemOrInstanceLoading: false }); + }); + } else { + this.setState({ + requestTypes: {}, + isRequestTypesReceived: false, + }); + + return findResource(RESOURCE_TYPES.INSTANCE, resultInstanceId) + .then((result) => { + if (!result || result.totalRecords === 0) { + this.setState({ + isItemOrInstanceLoading: false, + }); + + 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); + + onSetSelectedInstance(instance); + this.setState({ + isItemOrInstanceLoading: false, + }); + + return instance; + }) + .then(instance => { + if (instance && selectedUser?.id) { + const requester = getRequester(proxy, selectedUser); + this.findRequestTypes(instance.id, requester.id, ID_TYPE_MAP.INSTANCE_ID); + } + + return instance; + }) + .then(instance => { + this.findInstanceRelatedResources(instance); + + return instance; + }); + } + } + + triggerItemBarcodeValidation = () => { + const { + form, + values, + } = this.props; + + form.change('keyOfItemBarcodeField', values.keyOfItemBarcodeField ? 0 : 1); + }; + + triggerUserBarcodeValidation = () => { + const { + form, + values, + } = this.props; + + form.change('keyOfUserBarcodeField', values.keyOfUserBarcodeField ? 0 : 1); + }; + + triggerInstanceIdValidation = () => { + const { + form, + values, + } = this.props; + + form.change('keyOfInstanceIdField', values.keyOfInstanceIdField ? 0 : 1); + }; + + triggerRequestTypeValidation = () => { + const { + form, + values, + } = this.props; + + form.change('keyOfRequestTypeField', values.keyOfRequestTypeField ? 0 : 1); + }; + + onCancelRequest = (cancellationInfo) => { + this.setState({ isCancellingRequest: false }); + this.props.onCancelRequest(cancellationInfo); + } + + onCloseBlockedModal = () => { + const { + onSetBlocked, + } = this.props; + + onSetBlocked(false); + } + + onViewUserPath(selectedUser, patronGroup) { + // reinitialize form (mark it as pristine) + this.props.form.reset(); + // wait for the form to be reinitialized + defer(() => { + this.setState({ isCancellingRequest: false }); + const viewUserPath = `/users/view/${(selectedUser || {}).id}?filters=pg.${patronGroup.group}`; + this.props.history.push(viewUserPath); + }); + } + + renderAddRequestFirstMenu = () => ( + + + {title => ( + + )} + + + ); + + overridePatronBlocks = () => { + const { + onSetIsPatronBlocksOverridden, + } = this.props; + + onSetIsPatronBlocksOverridden(true); + }; + + handleTlrCheckboxChange = (event) => { + const isCreateTlr = event.target.checked; + const { + form, + selectedItem, + selectedInstance, + onSetSelectedItem, + onSetSelectedInstance, + } = this.props; + + form.change(REQUEST_FORM_FIELD_NAMES.CREATE_TLR, isCreateTlr); + form.change(REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, null); + form.change(REQUEST_FORM_FIELD_NAMES.INSTANCE_HRID, null); + 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); + } + } else if (selectedInstance) { + form.change(REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE, DEFAULT_REQUEST_TYPE_VALUE); + resetFieldState(form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE); + this.setState({ + isItemsDialogOpen: true, + }); + } else { + onSetSelectedInstance(undefined); + this.setState({ + requestTypes: {}, + isRequestTypesReceived: false, + }); + } + }; + + handleItemsDialogClose = () => { + const { + onSetSelectedInstance, + } = this.props; + + onSetSelectedInstance(undefined); + this.setState({ + isItemsDialogOpen: false, + requestTypes: {}, + isRequestTypesReceived: false, + isItemIdRequest: false, + }, this.triggerItemBarcodeValidation); + } + + handleInstanceItemClick = (event, item) => { + const { + onSetSelectedInstance, + } = this.props; + let isBarcodeRequired = false; + + onSetSelectedInstance(undefined); + this.setState({ + isItemsDialogOpen: false, + requestTypes: {}, + }); + + if (item?.barcode) { + isBarcodeRequired = true; + this.setState({ + isItemIdRequest: false, + }); + } + + this.findItem(RESOURCE_KEYS.id, item.id, false, isBarcodeRequired); + } + + handleCloseProxy = () => { + const { + onSetSelectedUser, + } = this.props; + + onSetSelectedUser(null); + this.setState({ + proxy: null, + }); + }; + + render() { + const { + handleSubmit, + request, + form, + optionLists: { + addressTypes, + }, + patronGroups, + parentResources, + submitting, + intl: { + formatMessage, + }, + errorMessage, + selectedItem, + selectedUser, + selectedInstance, + isPatronBlocksOverridden, + instanceId, + blocked, + values, + onCancel, + onGetAutomatedPatronBlocks, + onGetPatronManualBlocks, + isTlrEnabledOnEditPage, + optionLists, + pristine, + onSetSelectedItem, + onSetSelectedInstance, + metadataDisplay, + } = this.props; + + const { + selectedLoan, + itemRequestCount, + instanceRequestCount, + selectedAddressTypeId, + deliverySelected, + isCancellingRequest, + isUserLoading, + isItemOrInstanceLoading, + isAwaitingForProxySelection, + isItemsDialogOpen, + proxy, + requestTypes, + hasDelivery, + defaultDeliveryAddressTypeId, + isItemIdRequest, + isRequestTypesReceived, + isRequestTypeLoading, + } = this.state; + const { + createTitleLevelRequest, + } = values; + const patronBlocks = onGetPatronManualBlocks(parentResources); + const automatedPatronBlocks = onGetAutomatedPatronBlocks(parentResources); + const isEditForm = isFormEditing(request); + const selectedProxy = getProxy(request, proxy); + const requester = getRequester(selectedProxy, selectedUser); + let deliveryLocations; + let deliveryLocationsDetail = []; + let addressDetail; + if (requester?.personal?.addresses) { + deliveryLocations = requester.personal.addresses.map((a) => { + const typeName = find(addressTypes, { id: a.addressTypeId }).addressType; + return { label: typeName, value: a.addressTypeId }; + }); + deliveryLocations = sortBy(deliveryLocations, ['label']); + deliveryLocationsDetail = keyBy(requester.personal.addresses, a => a.addressTypeId); + } + + if (selectedAddressTypeId) { + addressDetail = toUserAddress(deliveryLocationsDetail[selectedAddressTypeId]); + } + + const patronGroup = getPatronGroup(requester, patronGroups); + const fulfillmentTypeOptions = getFulfillmentTypeOptions(hasDelivery, optionLists?.fulfillmentTypes || []); + const isSubmittingDisabled = isSubmittingButtonDisabled(pristine, submitting); + const isTitleLevelRequest = createTitleLevelRequest || request?.requestLevel === REQUEST_LEVEL_TYPES.TITLE; + const getPatronBlockModalOpenStatus = () => { + if (isAwaitingForProxySelection) { + return false; + } + + const isBlockedAndOverriden = blocked && !isPatronBlocksOverridden; + + return proxy?.id + ? isBlockedAndOverriden && (proxy.id === selectedUser?.id) + : isBlockedAndOverriden; + }; + + const handleCancelAndClose = () => { + const keepEditBtn = document.getElementById('clickable-cancel-editing-confirmation-confirm'); + if (isItemsDialogOpen) handleKeyCommand(this.handleItemsDialogClose); + else if (errorMessage) this.onClose(); + else if (keepEditBtn) keepEditBtn.click(); + else onCancel(); + }; + const isFulfilmentPreferenceVisible = (values.requestType || isEditForm) && !isRequestTypeLoading && isRequestTypesReceived; + const requestTypeOptions = Object.keys(requestTypes).map(requestType => { + return { + id: requestTypeOptionMap[requestType], + value: requestType, + }; + }); + + return ( + + +
+ + : + } + footer={ + +
+ + +
+
+ } + > + { + errorMessage && + } + errorMessage={parseErrorMessage(errorMessage)} + /> + } + { + this.state.titleLevelRequestsFeatureEnabled && !isEditForm && +
+ + + + + +
+ } + + + { + isTitleLevelRequest + ? ( + } + > +
+ +
+
+ ) + : ( + } + > +
+ +
+
+ ) + } + } + > +
+ +
+
+ } + > + + {isFulfilmentPreferenceVisible && + + } + +
+
+ this.setState({ isCancellingRequest: false })} + request={request} + stripes={this.props.stripes} + /> + this.onViewUserPath(selectedUser, patronGroup)} + patronBlocks={patronBlocks || []} + automatedPatronBlocks={automatedPatronBlocks} + /> + +
+
+
+
+ ); + } +} + +export default stripesFinalForm({ + navigationCheck: true, + subscription: { + values: true, + }, +})(RequestForm); diff --git a/src/deprecated/components/RequestForm/RequestForm.test.js b/src/deprecated/components/RequestForm/RequestForm.test.js new file mode 100644 index 00000000..25cad731 --- /dev/null +++ b/src/deprecated/components/RequestForm/RequestForm.test.js @@ -0,0 +1,1868 @@ +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import { + render, + screen, + fireEvent, + waitFor, +} from '@folio/jest-config-stripes/testing-library/react'; +import { + CommandList, + defaultKeyboardShortcuts, +} from '@folio/stripes/components'; + +import RequestForm, { + getRequestInformation, + getResourceTypeId, + ID_TYPE_MAP, +} from './RequestForm'; +import FulfilmentPreference from '../../../components/FulfilmentPreference'; +import RequesterInformation from '../../../components/RequesterInformation'; + +import { + REQUEST_LEVEL_TYPES, + createModes, + REQUEST_LAYERS, + REQUEST_FORM_FIELD_NAMES, + DEFAULT_REQUEST_TYPE_VALUE, + RESOURCE_KEYS, + REQUEST_OPERATIONS, +} from '../../../constants'; +import { RESOURCE_TYPES } from '../../constants'; +import { + getDefaultRequestPreferences, + isFormEditing, + getFulfillmentPreference, + resetFieldState, + getRequester, +} from '../../../utils'; +import { getTlrSettings } from '../../utils'; + +const testIds = { + tlrCheckbox: 'tlrCheckbox', + instanceInfoSection: 'instanceInfoSection', + fulfilmentPreferenceField: 'fulfilmentPreferenceField', + addressFiled: 'addressFiled', + itemField: 'itemField', + requesterField: 'requesterField', + instanceField: 'instanceField', + selectProxyButton: 'selectProxyButton', + closeProxyButton: 'closeProxyButton', + overridePatronButton: 'overridePatronButton', + closePatronModalButton: 'closePatronModalButton', + itemDialogCloseButton: 'itemDialogCloseButton', + itemDialogRow: 'itemDialogRow', +}; +const fieldValue = 'value'; +const idResourceType = 'id'; +const instanceId = 'instanceId'; +const item = { + id: 'itemId', + barcode: 'itemBarcode', +}; + +jest.mock('../../../utils', () => ({ + ...jest.requireActual('../../../utils'), + getRequestLevelValue: jest.fn(), + resetFieldState: jest.fn(), + getDefaultRequestPreferences: jest.fn(), + isFormEditing: jest.fn(), + getFulfillmentPreference: jest.fn(), + getRequester: jest.fn((proxy, selectedUser) => selectedUser), +})); +jest.mock('../../utils', () => ({ + getTlrSettings: jest.fn(() => ({ + titleLevelRequestsFeatureEnabled: true, + })), +})); +jest.mock('../../../components/FulfilmentPreference', () => jest.fn(({ + changeDeliveryAddress, + onChangeAddress, +}) => { + const handleFulfilmentPreferences = () => { + changeDeliveryAddress(true); + }; + + return ( + <> + + + + ); +})); +jest.mock('../../../components/RequesterInformation', () => jest.fn(({ + findUser, +}) => { + const handleChange = () => { + findUser(idResourceType, fieldValue, true); + }; + + return ( + + ); +})); +jest.mock('../../../components/RequestInformation', () => jest.fn(() =>
)); +jest.mock('../ItemInformation/ItemInformation', () => jest.fn(({ + findItem, + triggerValidation, +}) => { + const handleChange = () => { + triggerValidation(); + findItem(idResourceType, fieldValue, true); + }; + + return ( + + ); +})); +jest.mock('../InstanceInformation/InstanceInformation', () => jest.fn(({ + findInstance, + triggerValidation, +}) => { + const handleChange = () => { + triggerValidation(); + findInstance(instanceId, null, true); + }; + + return ( + + ); +})); +jest.mock('@folio/stripes/final-form', () => () => jest.fn((component) => component)); +jest.mock('../../../PatronBlockModal', () => jest.fn(({ + onOverride, + onClose, +}) => ( + <> + + + +))); +jest.mock('../../../CancelRequestDialog', () => jest.fn(() =>
)); +jest.mock('../../../components/TitleInformation', () => jest.fn(() =>
)); +jest.mock('../../../ItemDetail', () => jest.fn(() =>
)); +jest.mock('../ItemsDialog/ItemsDialog', () => jest.fn(({ + onClose, + onRowClick, +}) => { + const handleRowClick = () => { + onRowClick({}, item); + }; + + return ( + <> + + + + ); +})); +jest.mock('../../../PositionLink', () => jest.fn(() =>
)); + +describe('RequestForm', () => { + const labelIds = { + tlrCheckbox: 'ui-requests.requests.createTitleLevelRequest', + instanceInformation: 'ui-requests.instance.information', + enterButton:'ui-requests.enter', + requestInfoAccordion: 'ui-requests.requestMeta.information', + requesterInfoAccordion: 'ui-requests.requester.information', + }; + const basicProps = { + onGetPatronManualBlocks: jest.fn(), + onGetAutomatedPatronBlocks: jest.fn(), + onSetSelectedInstance: jest.fn(), + onSetSelectedItem: jest.fn(), + onSetSelectedUser: jest.fn(), + onSetInstanceId: jest.fn(), + onSetIsPatronBlocksOverridden: jest.fn(), + onSetBlocked: jest.fn(), + onShowErrorModal: jest.fn(), + onHideErrorModal: jest.fn(), + onChangePatron: jest.fn(), + form: { + change: jest.fn(), + }, + handleSubmit: jest.fn(), + asyncValidate: jest.fn(), + initialValues: {}, + location: { + pathname: 'pathname', + }, + onCancel: jest.fn(), + parentMutator: { + proxy: { + reset: jest.fn(), + GET: jest.fn(), + }, + }, + onSubmit: jest.fn(), + parentResources: { + patronBlocks: { + records: [], + }, + automatedPatronBlocks: { + isPending: false, + }, + }, + intl: { + formatMessage: ({ id }) => id, + }, + stripes: { + connect: jest.fn((component) => component), + store: { + getState: jest.fn(), + }, + }, + values: { + deliveryAddressTypeId: '', + pickupServicePointId: '', + createTitleLevelRequest: '', + }, + findResource: jest.fn(() => new Promise((resolve) => resolve())), + request: {}, + query: {}, + selectedItem: {}, + selectedUser: {}, + selectedInstance: {}, + isPatronBlocksOverridden: false, + isErrorModalOpen: false, + blocked: false, + optionLists: { + addressTypes: [ + { + id: 'addressTypeId', + addressType: 'Home', + } + ], + }, + }; + const defaultPreferences = { + defaultServicePointId: 'defaultServicePointId', + defaultDeliveryAddressTypeId: 'defaultDeliveryAddressTypeId', + }; + const fulfillmentPreference = {}; + const renderComponent = (passedProps = basicProps) => { + const history = createMemoryHistory(); + const { rerender } = render( + + + + + + ); + + return rerender; + }; + + getDefaultRequestPreferences.mockReturnValue(defaultPreferences); + getFulfillmentPreference.mockReturnValue(fulfillmentPreference); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when `TLR` in enabled', () => { + describe('when `isEdit` is false', () => { + beforeEach(() => { + isFormEditing.mockReturnValue(false); + }); + + describe('Initial render', () => { + const holding = { + holdingsRecords: [ + { + instanceId: 'instanceId', + } + ], + }; + const selectedItem = { + holdingsRecordId: 'holdingsRecordId', + }; + let findResource; + + beforeEach(() => { + findResource = jest.fn() + .mockResolvedValueOnce(holding) + .mockResolvedValueOnce(null); + + const props = { + ...basicProps, + selectedItem, + findResource, + }; + + renderComponent(props); + }); + + it('should render `TLR` checkbox section', () => { + const tlrCheckbox = screen.getByTestId(testIds.tlrCheckbox); + + expect(tlrCheckbox).toBeVisible(); + }); + + it('should reset instance and item info on TLR checkbox change', () => { + const expectedArgs = [ + ['item.barcode', null], + ['instance.hrid', null], + ['instanceId', null] + ]; + const tlrCheckbox = screen.getByTestId(testIds.tlrCheckbox); + + fireEvent.click(tlrCheckbox); + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toBeCalledWith(...args); + }); + }); + + it('should reset selected item', () => { + const tlrCheckbox = screen.getByTestId(testIds.tlrCheckbox); + + fireEvent.click(tlrCheckbox); + + expect(basicProps.onSetSelectedItem).toHaveBeenCalledWith(undefined); + }); + + it('should get instance id if it is not provided', () => { + const expectedArgs = [RESOURCE_TYPES.HOLDING, selectedItem.holdingsRecordId]; + const tlrCheckbox = screen.getByTestId(testIds.tlrCheckbox); + + fireEvent.click(tlrCheckbox); + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('`TLR` checkbox handle on first render', () => { + describe('when only `itemId` is passed in query', () => { + const props = { + ...basicProps, + query: { + itemId: 'itemId', + } + }; + + it('should set form `createTitleLevelRequest` value to false', () => { + const expectedArgs = ['createTitleLevelRequest', false]; + + renderComponent(props); + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('when only `itemBarcode` is passed in query', () => { + const props = { + ...basicProps, + query: { + itemBarcode: 'itemBarcode', + } + }; + + it('should set form `createTitleLevelRequest` value to false', () => { + const expectedArgs = ['createTitleLevelRequest', false]; + + renderComponent(props); + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('when only `instanceId` is passed in query', () => { + const props = { + ...basicProps, + query: { + instanceId: 'instanceId', + } + }; + + it('should set form `createTitleLevelRequest` value to true', () => { + const expectedArgs = ['createTitleLevelRequest', true]; + + renderComponent(props); + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + + describe('when `TLR` checkbox is checked', () => { + const props = { + ...basicProps, + values: { + ...basicProps.values, + createTitleLevelRequest: true, + }, + }; + + beforeEach(() => { + renderComponent(props); + }); + + it('should render Accordion with `Instance` information', () => { + const instanceInformation = screen.getByText(labelIds.instanceInformation); + + expect(instanceInformation).toBeVisible(); + }); + }); + + describe('when `TLR` checkbox is unchecked', () => { + const props = { + ...basicProps, + values: { + ...basicProps.values, + createTitleLevelRequest: false, + }, + }; + + it('should not render Accordion with `Instance` information', () => { + renderComponent(props); + + const instanceInformation = screen.queryByText(labelIds.instanceInformation); + + expect(instanceInformation).not.toBeInTheDocument(); + }); + }); + }); + + describe('when `isEdit` is true', () => { + const mockedInstance = { + id: 'instanceId', + title: 'instanceTitle', + contributors: 'instanceContributors', + publication: 'instancePublication', + editions: 'instanceEditions', + identifiers: 'instanceIdentifiers', + }; + const mockedRequest = { + instance : mockedInstance, + id : 'testId', + instanceId : 'instanceId', + requestType: 'Hold', + status: 'Open - Awaiting delivery', + }; + + beforeEach(() => { + isFormEditing.mockReturnValue(true); + }); + + it('should not render `TLR` checkbox section', () => { + const props = { + ...basicProps, + request: mockedRequest, + }; + + renderComponent(props); + + const tlrCheckbox = screen.queryByTestId(testIds.tlrCheckbox); + + expect(tlrCheckbox).not.toBeInTheDocument(); + }); + }); + }); + + describe('when `TLR` is disabled', () => { + beforeEach(() => { + getTlrSettings.mockReturnValue({ + titleLevelRequestsFeatureEnabled: false, + }); + renderComponent(); + }); + + it('should not display `TLR` checkbox', () => { + const tlrCheckbox = screen.queryByTestId(testIds.tlrCheckbox); + + expect(tlrCheckbox).not.toBeInTheDocument(); + }); + + it('should set form `createTitleLevelRequest` value to false', () => { + const expectedArgs = ['createTitleLevelRequest', false]; + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('when duplicate a request', () => { + const initialProps = { + ...basicProps, + query: { + mode: createModes.DUPLICATE, + }, + selectedUser: { + id: 'userId', + }, + request: { + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + }, + initialValues: { + item: { + id: 'itemId', + }, + }, + }; + const findResource = jest.fn().mockResolvedValue({}); + const newProps = { + ...initialProps, + selectedInstance: { + id: 'instanceId', + }, + selectedItem: null, + findResource, + }; + + beforeEach(() => { + const rerender = renderComponent(initialProps); + + isFormEditing.mockReturnValue(false); + + rerender( + + + + + + ); + }); + + it('should trigger "findResource" to find request types', () => { + expect(findResource).toHaveBeenCalledWith(RESOURCE_TYPES.REQUEST_TYPES, { + [ID_TYPE_MAP.INSTANCE_ID]: newProps.selectedInstance.id, + requesterId: newProps.selectedUser.id, + operation: REQUEST_OPERATIONS.CREATE, + }); + }); + + it('should set selected item', () => { + expect(basicProps.onSetSelectedItem).toHaveBeenCalledWith(initialProps.initialValues.item); + }); + + it('should trigger item barcode field validation', () => { + const expectedArgs = ['keyOfItemBarcodeField', expect.any(Number)]; + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('Request information', () => { + const requesterId = 'requesterId'; + + beforeEach(() => { + const newProps = { + ...basicProps, + query: { + layer: REQUEST_LAYERS.EDIT, + }, + request: { + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + requester: {}, + requesterId, + instanceId, + }, + values: { + ...basicProps.values, + requestType: 'requestType', + createTitleLevelRequest: true, + }, + selectedUser: { + id: 'userId', + personal: { + addresses: [ + { + addressTypeId: basicProps.optionLists.addressTypes[0].id, + } + ], + }, + }, + }; + const rerender = renderComponent(); + + rerender( + + + + + + ); + }); + + it('should render request information accordion', () => { + const requestInfoAccordion = screen.getByText(labelIds.requestInfoAccordion); + + expect(requestInfoAccordion).toBeInTheDocument(); + }); + + it('should trigger "FulfilmentPreference" with provided delivery locations', async () => { + const expectedProps = { + deliveryLocations: [ + { + label: basicProps.optionLists.addressTypes[0].addressType, + value: basicProps.optionLists.addressTypes[0].id, + } + ], + }; + + await waitFor(() => { + expect(FulfilmentPreference).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + }); + + it('should handle changing of address field', async () => { + const addressField = await screen.findByTestId(testIds.addressFiled); + const event = { + target: { + value: 'selectedAddressTypeId', + }, + }; + const expectedArgs = [REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, event.target.value]; + + fireEvent.change(addressField, event); + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should handle changing of fulfilment preferences field', async () => { + const fulfilmentPreferenceField = await screen.findByTestId(testIds.fulfilmentPreferenceField); + const event = { + target: { + value: 'fulfilmentPreferences', + }, + }; + const expectedArgs = [ + [REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, defaultPreferences.defaultDeliveryAddressTypeId], + [REQUEST_FORM_FIELD_NAMES.PICKUP_SERVICE_POINT_ID, ''] + ]; + + fireEvent.change(fulfilmentPreferenceField, event); + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toHaveBeenCalledWith(...args); + }); + }); + + it('should trigger "form.change" with correct arguments', () => { + const expectedArgs = [REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE, DEFAULT_REQUEST_TYPE_VALUE]; + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should trigger "findResource" with correct arguments', () => { + const expectedArgs = [ + RESOURCE_TYPES.REQUEST_TYPES, + { + [ID_TYPE_MAP.INSTANCE_ID]: instanceId, + requesterId, + operation: REQUEST_OPERATIONS.CREATE, + } + ]; + + expect(basicProps.findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('Requester information', () => { + const requestPreferencesResult = { + requestPreferences: [ + { + delivery: true, + } + ], + }; + const manualBlocks = [ + { + userId: 'id', + } + ]; + const userResult = { + totalRecords: 1, + users: [ + { + id: 'userId', + } + ], + }; + + beforeEach(() => { + isFormEditing.mockReturnValue(false); + basicProps.onGetPatronManualBlocks.mockReturnValue(manualBlocks); + }); + + describe('When userId is presented', () => { + const initialUserId = 'userId'; + const updatedUserId = 'updatedUserId'; + + describe('Initial rendering', () => { + const automatedBlocks = [{}]; + const userProxies = []; + let findResource; + + beforeEach(() => { + basicProps.onGetAutomatedPatronBlocks.mockReturnValue(automatedBlocks); + basicProps.parentMutator.proxy.GET.mockResolvedValue(userProxies); + findResource = jest.fn() + .mockResolvedValueOnce(userResult) + .mockResolvedValueOnce(requestPreferencesResult) + .mockResolvedValue({}); + + const props = { + ...basicProps, + findResource, + query: { + layer: REQUEST_LAYERS.CREATE, + userId: initialUserId, + }, + }; + + renderComponent(props); + }); + + it('should render requester information accordion', () => { + const requesterInfoAccordion = screen.getByText(labelIds.requesterInfoAccordion); + + expect(requesterInfoAccordion).toBeInTheDocument(); + }); + + it('should reset user related fields', () => { + const expectedArgs = [ + [REQUEST_FORM_FIELD_NAMES.PICKUP_SERVICE_POINT_ID, undefined], + [REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, undefined], + [REQUEST_FORM_FIELD_NAMES.PROXY_USER_ID, undefined] + ]; + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toHaveBeenCalledWith(...args); + }); + }); + + it('should set user related information', () => { + const expectedArgs = [ + [REQUEST_FORM_FIELD_NAMES.REQUESTER_ID, userResult.users[0].id], + [REQUEST_FORM_FIELD_NAMES.REQUESTER, userResult.users[0]] + ]; + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toHaveBeenCalledWith(...args); + }); + }); + + it('should get user data using user id', () => { + const expectedArgs = [ + RESOURCE_TYPES.USER, + initialUserId, + RESOURCE_KEYS.id, + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should get manual blocks information', () => { + expect(basicProps.onGetPatronManualBlocks).toHaveBeenCalledWith(basicProps.parentResources); + }); + + it('should get automated blocks information', () => { + expect(basicProps.onGetAutomatedPatronBlocks).toHaveBeenCalledWith(basicProps.parentResources); + }); + + it('should change patron information', () => { + expect(basicProps.onChangePatron).toHaveBeenCalledWith(userResult.users[0]); + }); + + it('should set selected user', () => { + expect(basicProps.onSetSelectedUser).toHaveBeenCalledWith(userResult.users[0]); + }); + + it('should trigger validation of user barcode field', () => { + const expectedArg = ['keyOfUserBarcodeField', 1]; + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArg); + }); + + it('should get request preferences data', () => { + const expectedArgs = [ + 'requestPreferences', + userResult.users[0].id, + 'userId', + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should set fulfilment preference information', async () => { + const expectedArgs = [ + REQUEST_FORM_FIELD_NAMES.FULFILLMENT_PREFERENCE, + fulfillmentPreference + ]; + + await waitFor(() => { + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + it('should set blocked to true', () => { + expect(basicProps.onSetBlocked).toHaveBeenCalledWith(true); + }); + + it('should set isPatronBlocksOverridden to false', () => { + expect(basicProps.onSetIsPatronBlocksOverridden).toHaveBeenCalledWith(false); + }); + + it('should set default service point', async () => { + const expectedArgs = [ + REQUEST_FORM_FIELD_NAMES.PICKUP_SERVICE_POINT_ID, + defaultPreferences.defaultServicePointId + ]; + + await waitFor(() => { + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + it('should reset delivery address type id', async () => { + const expectedArgs = [ + REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, + '' + ]; + + await waitFor(() => { + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + it('should reset proxy information', () => { + expect(basicProps.parentMutator.proxy.reset).toHaveBeenCalled(); + }); + + it('should get proxy information', () => { + const expectedArg = { + params: { + query: `query=(proxyUserId==${userResult.users[0].id})`, + }, + }; + + expect(basicProps.parentMutator.proxy.GET).toHaveBeenCalledWith(expectedArg); + }); + + it('should handle requester barcode field change', () => { + const expectedArgs = [RESOURCE_TYPES.USER, fieldValue, RESOURCE_KEYS.id]; + const requesterField = screen.getByTestId(testIds.requesterField); + const event = { + target: { + value: 'value', + }, + }; + + fireEvent.change(requesterField, event); + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('Component updating', () => { + const findResource = jest.fn(() => Promise.resolve({})); + const props = { + ...basicProps, + query: { + layer: REQUEST_LAYERS.CREATE, + userId: initialUserId, + }, + findResource, + }; + const newProps = { + ...basicProps, + values: {}, + request: {}, + query: { + layer: REQUEST_LAYERS.CREATE, + userId: updatedUserId, + }, + findResource, + }; + + beforeEach(() => { + const rerender = renderComponent(props); + + rerender( + + + + + + ); + }); + + it('should get user data by user id', () => { + const expectedArgs = [ + RESOURCE_TYPES.USER, + updatedUserId, + RESOURCE_KEYS.id, + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('Proxy handling', () => { + const selectedUser = { + id: 'userId', + }; + let findResource; + + beforeEach(() => { + const automatedBlocks = []; + + findResource = jest.fn() + .mockResolvedValueOnce(userResult) + .mockResolvedValueOnce(requestPreferencesResult); + basicProps.onGetAutomatedPatronBlocks.mockReturnValue(automatedBlocks); + }); + + describe('When user acts as a proxy', () => { + const proxy = { + id: 'proxyId', + }; + + beforeEach(() => { + const props = { + ...basicProps, + query: { + layer: REQUEST_LAYERS.CREATE, + userId: initialUserId, + }, + selectedUser, + findResource, + }; + const userProxies = [ + { + ...proxy, + } + ]; + + RequesterInformation.mockImplementation(({ + onSelectProxy, + handleCloseProxy, + }) => { + const handleSelectProxy = () => { + onSelectProxy(proxy); + }; + + return ( + <> + + + + ); + }); + basicProps.parentMutator.proxy.GET.mockResolvedValue(userProxies); + + renderComponent(props); + }); + + it('should set selected user', () => { + const selectProxyButton = screen.getByTestId(testIds.selectProxyButton); + + fireEvent.click(selectProxyButton); + + expect(basicProps.onSetSelectedUser).toHaveBeenCalledWith(selectedUser); + }); + + it('should change requester related fields', () => { + const expectedArgs = [ + [REQUEST_FORM_FIELD_NAMES.REQUESTER_ID, proxy.id], + [REQUEST_FORM_FIELD_NAMES.PROXY_USER_ID, selectedUser.id] + ]; + const selectProxyButton = screen.getByTestId(testIds.selectProxyButton); + + fireEvent.click(selectProxyButton); + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toHaveBeenCalledWith(...args); + }); + }); + + it('should set selected user to null', () => { + const closeProxyButton = screen.getByTestId(testIds.closeProxyButton); + + fireEvent.click(closeProxyButton); + + expect(basicProps.onSetSelectedUser).toHaveBeenCalledWith(null); + }); + }); + + describe('When user acts as himself', () => { + const proxy = { + id: selectedUser.id, + }; + + beforeEach(() => { + const props = { + ...basicProps, + query: { + layer: REQUEST_LAYERS.CREATE, + userId: initialUserId, + }, + selectedUser, + findResource, + }; + const userProxies = [ + { + ...proxy, + } + ]; + + RequesterInformation.mockImplementation(({ + onSelectProxy, + }) => { + const handleSelectProxy = () => { + onSelectProxy(proxy); + }; + + return ( + <> + + + ); + }); + basicProps.parentMutator.proxy.GET.mockResolvedValue(userProxies); + + renderComponent(props); + }); + + it('should change requester related fields', () => { + const expectedArgs = [REQUEST_FORM_FIELD_NAMES.REQUESTER_ID, selectedUser.id]; + const selectProxyButton = screen.getByTestId(testIds.selectProxyButton); + + fireEvent.click(selectProxyButton); + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + }); + + describe('When userBarcode is presented', () => { + const initialUserBarcode = 'userBarcode'; + const updatedUserBarcode = 'updatedUserBarcode'; + + describe('Initial rendering', () => { + const automatedBlocks = []; + const userProxies = [{}]; + let findResource; + + beforeEach(() => { + basicProps.onGetAutomatedPatronBlocks.mockReturnValue(automatedBlocks); + basicProps.parentMutator.proxy.GET.mockResolvedValue(userProxies); + findResource = jest.fn() + .mockResolvedValueOnce(userResult) + .mockResolvedValueOnce(requestPreferencesResult); + + const props = { + ...basicProps, + query: { + layer: REQUEST_LAYERS.CREATE, + userBarcode: initialUserBarcode, + }, + findResource, + }; + + renderComponent(props); + }); + + it('should reset user related fields', () => { + const expectedArgs = [ + [REQUEST_FORM_FIELD_NAMES.PICKUP_SERVICE_POINT_ID, undefined], + [REQUEST_FORM_FIELD_NAMES.DELIVERY_ADDRESS_TYPE_ID, undefined], + [REQUEST_FORM_FIELD_NAMES.PROXY_USER_ID, undefined] + ]; + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toHaveBeenCalledWith(...args); + }); + }); + + it('should get user data using barcode', () => { + const expectedArgs = [ + RESOURCE_TYPES.USER, + initialUserBarcode, + RESOURCE_KEYS.barcode, + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should not trigger validation of user barcode field', () => { + const expectedArg = ['keyOfUserBarcodeField', expect.any(Number)]; + + expect(basicProps.form.change).not.toHaveBeenCalledWith(...expectedArg); + }); + + it('should not set blocked to true', () => { + expect(basicProps.onSetBlocked).not.toHaveBeenCalledWith(true); + }); + + it('should not set isPatronBlocksOverridden to false', () => { + expect(basicProps.onSetIsPatronBlocksOverridden).not.toHaveBeenCalledWith(false); + }); + }); + + describe('Component updating', () => { + const findResource = jest.fn(() => Promise.resolve({})); + const props = { + ...basicProps, + findResource, + query: { + layer: REQUEST_LAYERS.CREATE, + userBarcode: initialUserBarcode, + }, + }; + const newProps = { + ...basicProps, + findResource, + query: { + layer: REQUEST_LAYERS.CREATE, + userBarcode: updatedUserBarcode, + }, + }; + + beforeEach(() => { + const rerender = renderComponent(props); + + rerender( + + + + + + ); + }); + + it('should get user data by user barcode', () => { + const expectedArgs = [ + RESOURCE_TYPES.USER, + updatedUserBarcode, + RESOURCE_KEYS.barcode, + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + }); + + describe('Item information', () => { + describe('When item barcode is presented', () => { + const initialItemBarcode = 'itemBarcode'; + const updatedItemBarcode = 'updatedItemBarcode'; + + describe('Initial render', () => { + const requestPreferencesResult = {}; + + beforeEach(() => { + isFormEditing.mockReturnValue(true); + }); + + describe('When item is found', () => { + const event = { + target: { + value: 'barcode', + }, + }; + const itemResult = { + totalRecords: 1, + items: [ + { + id: 'itemId', + barcode: initialItemBarcode, + holdingsRecordId: 'holdingsRecordId', + } + ], + }; + const requestTypesResult = { + 'Page': [ + { + id: 'id', + name: 'Circ Desk 1', + } + ] + }; + const loanResult = { + loans: [ + { + id: 'loanId', + } + ], + }; + const itemRequestsResult = { + requests: [], + }; + const holdingsRecordResult = { + holdingsRecords: [ + { + instanceId, + } + ], + }; + let findResource; + + beforeEach(() => { + findResource = jest.fn() + .mockResolvedValueOnce(itemResult) + .mockResolvedValueOnce(requestPreferencesResult) + .mockResolvedValueOnce(requestTypesResult) + .mockResolvedValueOnce(loanResult) + .mockResolvedValueOnce(itemRequestsResult) + .mockResolvedValueOnce(holdingsRecordResult) + .mockResolvedValue({}); + + const props = { + ...basicProps, + selectedUser: { + id: 'selectedUserId', + }, + query: { + itemBarcode: initialItemBarcode, + }, + request: { + id: 'requestId', + }, + findResource, + }; + + renderComponent(props); + }); + + it('should get information about requested item', () => { + const expectedArgs = [ + RESOURCE_TYPES.ITEM, + initialItemBarcode, + RESOURCE_KEYS.barcode + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should set item information', () => { + const expectedArgs = [ + [REQUEST_FORM_FIELD_NAMES.ITEM_ID, itemResult.items[0].id], + [REQUEST_FORM_FIELD_NAMES.ITEM_BARCODE, itemResult.items[0].barcode] + ]; + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toHaveBeenCalledWith(...args); + }); + }); + + it('should reset field state for request type', () => { + const expectedArgs = [basicProps.form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE]; + + expect(resetFieldState).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should get requester information', () => { + expect(getRequester).toHaveBeenCalled(); + }); + + it('should get information about loans', () => { + const expectedArgs = [ + 'loan', + itemResult.items[0].id + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should get information about open item requests', () => { + const expectedArgs = [ + 'requestsForItem', + itemResult.items[0].id + ]; + + 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); + + fireEvent.change(itemField, event); + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should trigger item barcode field validation', () => { + const expectedArgs = ['keyOfItemBarcodeField', expect.any(Number)]; + const itemField = screen.getByTestId(testIds.itemField); + + fireEvent.change(itemField, event); + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('When item is not found', () => { + const itemResult = { + totalRecords: 0, + items: [], + }; + let findResource; + + beforeEach(() => { + findResource = jest.fn() + .mockResolvedValueOnce(itemResult) + .mockResolvedValueOnce(requestPreferencesResult); + + const props = { + ...basicProps, + selectedUser: { + id: 'selectedUserId', + }, + query: { + itemBarcode: initialItemBarcode, + }, + request: { + id: 'requestId', + }, + findResource, + }; + + renderComponent(props); + }); + + it('should get information about requested item', () => { + const expectedArgs = [ + RESOURCE_TYPES.ITEM, + initialItemBarcode, + RESOURCE_KEYS.barcode + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should not reset field state for request type', () => { + const expectedArgs = [basicProps.form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE]; + + expect(resetFieldState).not.toHaveBeenCalledWith(...expectedArgs); + }); + + it('should not get request types information', () => { + const expectedArgs = [RESOURCE_TYPES.REQUEST_TYPES, expect.any(Object)]; + + expect(findResource).not.toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + + describe('Component updating', () => { + const findResource = jest.fn(() => Promise.resolve()); + const props = { + ...basicProps, + query: { + itemBarcode: initialItemBarcode, + }, + findResource, + }; + const newProps = { + ...basicProps, + query: { + itemBarcode: updatedItemBarcode, + }, + findResource, + }; + + beforeEach(() => { + const rerender = renderComponent(props); + + rerender( + + + + + + ); + }); + + it('should get item data by item barcode', () => { + const expectedArgs = [ + RESOURCE_TYPES.ITEM, + updatedItemBarcode, + RESOURCE_KEYS.barcode, + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + + describe('When item id is presented', () => { + const initialItemId = 'itemId'; + const updatedItemId = 'updatedItemId'; + const itemResult = { + totalRecords: 0, + items: [], + }; + + describe('Initial render', () => { + const findResource = jest.fn().mockResolvedValueOnce(itemResult); + const props = { + ...basicProps, + query: { + itemId: initialItemId, + }, + findResource, + }; + + beforeEach(() => { + renderComponent(props); + }); + + it('should get information about requested item by item id', () => { + const expectedArgs = [ + RESOURCE_TYPES.ITEM, + initialItemId, + RESOURCE_KEYS.id + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('Component updating', () => { + const findResource = jest.fn().mockResolvedValue(itemResult); + const props = { + ...basicProps, + query: { + itemId: initialItemId, + }, + findResource, + }; + const newProps = { + ...basicProps, + query: { + itemId: updatedItemId, + }, + findResource, + }; + + beforeEach(() => { + const rerender = renderComponent(props); + + rerender( + + + + + + ); + }); + + it('should get information about requested item after component updating', () => { + const expectedArgs = [ + RESOURCE_TYPES.ITEM, + updatedItemId, + RESOURCE_KEYS.id + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + }); + + describe('Instance information', () => { + const initialInstanceId = 'instanceId'; + const updatedInstanceId = 'updatedInstanceId'; + + describe('Initial render', () => { + describe('When instance is found', () => { + const event = { + target: { + value: 'value', + }, + }; + const instanceResult = { + totalRecords: 1, + instances: [ + { + id: initialInstanceId, + hrid: 'hrid', + } + ], + }; + const requestTypesResult = { + 'Page': [ + { + id: 'id', + name: 'Circ Desk 1', + } + ] + }; + const instanceRequestsResult = { + requests: [ + { + requestLevel: REQUEST_LEVEL_TYPES.ITEM, + } + ], + }; + let findResource; + + beforeEach(() => { + findResource = jest.fn() + .mockResolvedValueOnce(instanceResult) + .mockResolvedValueOnce(requestTypesResult) + .mockResolvedValue(instanceRequestsResult); + + const props = { + ...basicProps, + selectedUser: { + id: 'selectedUserId', + }, + query: { + instanceId: initialInstanceId, + }, + request: { + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + }, + findResource, + }; + + renderComponent(props); + }); + + it('should get information about requested instance', () => { + const expectedArgs = [ + RESOURCE_TYPES.INSTANCE, + initialInstanceId, + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + 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] + ]; + + expectedArgs.forEach(args => { + expect(basicProps.form.change).toHaveBeenCalledWith(...args); + }); + }); + + it('should reset field state for request type', () => { + const expectedArgs = [basicProps.form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE]; + + expect(resetFieldState).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should get requester information', () => { + expect(getRequester).toHaveBeenCalled(); + }); + + it('should get information about open instance requests', () => { + const expectedArgs = [ + 'requestsForInstance', + instanceResult.instances[0].id + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should set selected instance', () => { + expect(basicProps.onSetSelectedInstance).toHaveBeenCalledWith(instanceResult.instances[0]); + }); + + it('should handle instance id field change', () => { + const expectedArgs = [RESOURCE_TYPES.INSTANCE, instanceId]; + const instanceField = screen.getByTestId(testIds.instanceField); + + fireEvent.change(instanceField, event); + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should trigger instance id field validation', () => { + const expectedArgs = ['keyOfInstanceIdField', expect.any(Number)]; + const instanceField = screen.getByTestId(testIds.instanceField); + + fireEvent.change(instanceField, event); + + expect(basicProps.form.change).toHaveBeenCalledWith(...expectedArgs); + }); + }); + + describe('When instance is not found', () => { + const instanceResult = { + totalRecords: 0, + instances: [], + }; + let findResource; + + beforeEach(() => { + findResource = jest.fn().mockResolvedValueOnce(instanceResult); + + const props = { + ...basicProps, + query: { + instanceId: initialInstanceId, + }, + findResource, + }; + + renderComponent(props); + }); + + it('should get information about requested instance', () => { + const expectedArgs = [ + RESOURCE_TYPES.INSTANCE, + initialInstanceId, + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should not get request types information', () => { + const expectedArgs = [RESOURCE_TYPES.REQUEST_TYPES, expect.any(Object)]; + + expect(findResource).not.toHaveBeenCalledWith(...expectedArgs); + }); + + it('should not reset field state for request type', () => { + const expectedArgs = [basicProps.form, REQUEST_FORM_FIELD_NAMES.REQUEST_TYPE]; + + expect(resetFieldState).not.toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + + describe('Component updating', () => { + const findResource = jest.fn(() => Promise.resolve()); + const props = { + ...basicProps, + findResource, + query: { + instanceId: initialInstanceId, + }, + }; + const newProps = { + ...basicProps, + findResource, + query: { + instanceId: updatedInstanceId, + }, + }; + + beforeEach(() => { + const rerender = renderComponent(props); + + rerender( + + + + + + ); + }); + + it('should get information about requested instance after component updating', () => { + const expectedArgs = [ + RESOURCE_TYPES.INSTANCE, + updatedInstanceId + ]; + + expect(findResource).toHaveBeenCalledWith(...expectedArgs); + }); + }); + }); + + describe('Patron block modal', () => { + beforeEach(() => { + renderComponent(); + }); + + it('should set isPatronBlocksOverridden to true', () => { + const overridePatronButton = screen.getByTestId(testIds.overridePatronButton); + + fireEvent.click(overridePatronButton); + + expect(basicProps.onSetIsPatronBlocksOverridden).toHaveBeenCalledWith(true); + }); + + it('should set blocked to false', () => { + const closePatronModalButton = screen.getByTestId(testIds.closePatronModalButton); + + fireEvent.click(closePatronModalButton); + + expect(basicProps.onSetBlocked).toHaveBeenCalledWith(false); + }); + }); + + describe('Items dialog', () => { + beforeEach(() => { + renderComponent(); + }); + + it('should get information about selected item', () => { + const expectedArgs = [RESOURCE_TYPES.ITEM, item.id, RESOURCE_KEYS.id]; + const itemDialogRow = screen.getByTestId(testIds.itemDialogRow); + + fireEvent.click(itemDialogRow); + + expect(basicProps.findResource).toHaveBeenCalledWith(...expectedArgs); + }); + + it('should reset selected instance', () => { + const itemDialogCloseButton = screen.getByTestId(testIds.itemDialogCloseButton); + + fireEvent.click(itemDialogCloseButton); + + expect(basicProps.onSetSelectedInstance).toHaveBeenCalledWith(undefined); + }); + }); + + describe('getResourceTypeId', () => { + it('should return instance id type', () => { + expect(getResourceTypeId(true)).toBe(ID_TYPE_MAP.INSTANCE_ID); + }); + + it('should return item id type', () => { + expect(getResourceTypeId(false)).toBe(ID_TYPE_MAP.ITEM_ID); + }); + }); + + describe('getRequestInformation', () => { + describe('when title level request', () => { + const selectedInstance = { + id: 'instanceId', + }; + const args = [ + {}, + selectedInstance, + {}, + { + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + }, + ]; + + it('should return correct data', () => { + const expectedResult = { + isTitleLevelRequest: true, + selectedResource: selectedInstance, + }; + + expect(getRequestInformation(...args)).toEqual(expectedResult); + }); + }); + + describe('when item level request', () => { + const selectedItem = { + id: 'itemId', + }; + const args = [ + {}, + {}, + selectedItem, + { + requestLevel: REQUEST_LEVEL_TYPES.ITEM, + }, + ]; + + it('should return correct data', () => { + const expectedResult = { + isTitleLevelRequest: false, + selectedResource: selectedItem, + }; + + expect(getRequestInformation(...args)).toEqual(expectedResult); + }); + }); + }); +}); diff --git a/src/deprecated/components/RequestFormContainer/RequestFormContainer.js b/src/deprecated/components/RequestFormContainer/RequestFormContainer.js new file mode 100644 index 00000000..4b60e9d7 --- /dev/null +++ b/src/deprecated/components/RequestFormContainer/RequestFormContainer.js @@ -0,0 +1,202 @@ +import { + useState, +} from 'react'; +import { + useIntl, +} from 'react-intl'; +import { + cloneDeep, + isEmpty, + isString, + unset, +} from 'lodash'; +import moment from 'moment-timezone'; +import PropTypes from 'prop-types'; + +import RequestForm from '../RequestForm/RequestForm'; +import { + getRequestLevelValue, +} from '../../../utils'; +import { + fulfillmentTypeMap, + REQUEST_LEVEL_TYPES, +} from '../../../constants'; +import { RESOURCE_TYPES } from '../../constants'; + +const RequestFormContainer = ({ + parentResources, + request, + onSubmit, + ...rest +}) => { + const { + requester, + requesterId, + item, + } = request || {}; + const intl = useIntl(); + const [selectedItem, setSelectedItem] = useState(item); + 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) => { + setSelectedItem(optedItem); + }; + + const setUser = (user) => { + setSelectedUser(user); + }; + + const setInstance = (instance) => { + setSelectedInstance(instance); + }; + + const setIsBlocked = (value) => { + setBlocked(value); + }; + + const setStateIsPatronBlocksOverridden = (value) => { + setIsPatronBlocksOverridden(value); + }; + + const setStateInstanceId = (id) => { + setInstanceId(id); + }; + + const getPatronManualBlocks = (resources) => { + return (resources?.patronBlocks?.records || []) + .filter(b => b.requests === true) + .filter(p => moment(moment(p.expirationDate).format()).isSameOrAfter(moment().format())); + }; + + const getAutomatedPatronBlocks = (resources) => { + const automatedPatronBlocks = resources?.automatedPatronBlocks?.records || []; + + return automatedPatronBlocks.reduce((blocks, block) => { + if (block.blockRequests) { + blocks.push(block.message); + } + + return blocks; + }, []); + }; + + const hasBlocking = () => { + const [block = {}] = getPatronManualBlocks(parentResources); + const automatedPatronBlocks = getAutomatedPatronBlocks(parentResources); + const isBlocked = ( + (block?.userId === selectedUser.id || !isEmpty(automatedPatronBlocks)) && + !isPatronBlocksOverridden + ); + + setIsBlocked(isBlocked); + + return isBlocked; + }; + + const handleSubmit = (data) => { + const { + timeZone, + } = intl; + + const requestData = cloneDeep(data); + + const { + requestExpirationDate, + holdShelfExpirationDate, + holdShelfExpirationTime, + fulfillmentPreference, + deliveryAddressTypeId, + pickupServicePointId, + } = requestData; + + if (hasBlocking()) return undefined; + + if (!requestExpirationDate) { + unset(requestData, 'requestExpirationDate'); + } + if (holdShelfExpirationDate) { + // Recombine the values from datepicker and timepicker into a single date/time + const date = moment.tz(holdShelfExpirationDate, timeZone).format('YYYY-MM-DD'); + const time = holdShelfExpirationTime.replace('Z', ''); + const combinedDateTime = moment.tz(`${date} ${time}`, timeZone); + requestData.holdShelfExpirationDate = combinedDateTime.utc().format(); + } else { + unset(requestData, 'holdShelfExpirationDate'); + } + if (fulfillmentPreference === fulfillmentTypeMap.HOLD_SHELF && isString(deliveryAddressTypeId)) { + unset(requestData, 'deliveryAddressTypeId'); + } + if (fulfillmentPreference === fulfillmentTypeMap.DELIVERY && isString(pickupServicePointId)) { + unset(requestData, 'pickupServicePointId'); + } + + if (isPatronBlocksOverridden) { + requestData.requestProcessingParameters = { + overrideBlocks: { + patronBlock: {}, + }, + }; + } + + requestData.instanceId = request?.instanceId || instanceId || selectedInstance?.id; + requestData.requestLevel = request?.requestLevel || getRequestLevelValue(requestData.createTitleLevelRequest); + + if (requestData.requestLevel === REQUEST_LEVEL_TYPES.ITEM) { + requestData.holdingsRecordId = request?.holdingsRecordId || selectedItem?.holdingsRecordId; + } + + if (requestData.requestLevel === REQUEST_LEVEL_TYPES.TITLE) { + unset(requestData, 'itemId'); + unset(requestData, 'holdingsRecordId'); + unset(requestData, RESOURCE_TYPES.ITEM); + } + + unset(requestData, 'itemRequestCount'); + unset(requestData, 'titleRequestCount'); + unset(requestData, 'createTitleLevelRequest'); + unset(requestData, 'numberOfReorderableRequests'); + unset(requestData, RESOURCE_TYPES.INSTANCE); + unset(requestData, 'keyOfItemBarcodeField'); + unset(requestData, 'keyOfUserBarcodeField'); + unset(requestData, 'keyOfInstanceIdField'); + unset(requestData, 'keyOfRequestTypeField'); + + return onSubmit(requestData); + }; + + return ( + + ); +}; + +RequestFormContainer.propTypes = { + request: PropTypes.object, + parentResources: PropTypes.object, + onSubmit: PropTypes.func.isRequired, +}; + +export default RequestFormContainer; diff --git a/src/deprecated/components/RequestFormContainer/RequestFormContainer.test.js b/src/deprecated/components/RequestFormContainer/RequestFormContainer.test.js new file mode 100644 index 00000000..6c858f75 --- /dev/null +++ b/src/deprecated/components/RequestFormContainer/RequestFormContainer.test.js @@ -0,0 +1,388 @@ +import { + render, + screen, + fireEvent, +} from '@folio/jest-config-stripes/testing-library/react'; + +import RequestFormContainer from './RequestFormContainer'; +import RequestForm from '../RequestForm/RequestForm'; +import { + REQUEST_LEVEL_TYPES, + fulfillmentTypeMap, +} from '../../../constants'; + +jest.mock('../RequestForm/RequestForm', () => jest.fn(() =>
)); + +const defaultProps = { + parentResources: {}, + request: { + item: {}, + instance: {}, + requester: {}, + requesterId: 'requesterId', + instanceId: 'instanceId', + holdingsRecordId: '', + }, + onSubmit: jest.fn(), +}; +const testIds = { + requestForm: 'requestForm', +}; + +describe('RequestFormContainer', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Initial render', () => { + beforeEach(() => { + render( + + ); + }); + + it('should trigger RequestForm with correct props', () => { + const expectedProps = { + parentResources: defaultProps.parentResources, + request: defaultProps.request, + blocked: false, + selectedItem: defaultProps.request.item, + selectedUser: { + ...defaultProps.request.requester, + id: defaultProps.request.requesterId, + }, + selectedInstance: defaultProps.request.instance, + isPatronBlocksOverridden: false, + instanceId: '', + onGetPatronManualBlocks: expect.any(Function), + onGetAutomatedPatronBlocks: expect.any(Function), + onSetBlocked: expect.any(Function), + onSetSelectedItem: expect.any(Function), + onSetSelectedUser: expect.any(Function), + onSetSelectedInstance: expect.any(Function), + onSetIsPatronBlocksOverridden: expect.any(Function), + onSetInstanceId: expect.any(Function), + onSubmit: expect.any(Function), + }; + + expect(RequestForm).toHaveBeenCalledWith(expect.objectContaining(expectedProps), {}); + }); + }); + + describe('Submit handling', () => { + const basicSubmitData = { + requestExpirationDate: null, + fulfillmentPreference: null, + holdShelfExpirationDate: null, + pickupServicePointId: 'pickupServicePointId', + deliveryAddressTypeId: 'deliveryAddressTypeId', + holdingsRecordId: null, + itemRequestCount: null, + titleRequestCount: null, + createTitleLevelRequest: false, + numberOfReorderableRequests: null, + instance: null, + keyOfItemBarcodeField: 0, + keyOfUserBarcodeField: 0, + keyOfInstanceIdField: 0, + keyOfRequestTypeField: 0, + }; + + describe('When item level request', () => { + const requestExpirationDate = new Date().toISOString(); + const submitData = { + ...basicSubmitData, + requestLevel: REQUEST_LEVEL_TYPES.ITEM, + fulfillmentPreference: fulfillmentTypeMap.HOLD_SHELF, + requestExpirationDate, + }; + const props = { + ...defaultProps, + itemId: 'itemId', + item: { + id: 'id', + }, + }; + const selectedItem = { + holdingsRecordId: 'holdingsRecordId', + }; + const selectItemLabel = 'Select Item'; + + beforeEach(() => { + RequestForm.mockImplementation(({ + onSubmit, + onSetSelectedItem, + }) => ( + <> +
onSubmit(submitData)} + /> + + + )); + + render( + + ); + + const selectItemButton = screen.getByText(selectItemLabel); + + fireEvent.click(selectItemButton); + }); + + it('should submit form data', () => { + const expectedArg = { + holdingsRecordId: selectedItem.holdingsRecordId, + fulfillmentPreference: fulfillmentTypeMap.HOLD_SHELF, + instanceId: defaultProps.request.instanceId, + requestLevel: REQUEST_LEVEL_TYPES.ITEM, + pickupServicePointId: submitData.pickupServicePointId, + item: submitData.item, + itemId: submitData.itemId, + requestExpirationDate, + }; + const requestForm = screen.getByTestId(testIds.requestForm); + + fireEvent.submit(requestForm); + + expect(defaultProps.onSubmit).toHaveBeenCalledWith(expectedArg); + }); + }); + + describe('When title level request', () => { + const submitData = { + ...basicSubmitData, + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + fulfillmentPreference: fulfillmentTypeMap.DELIVERY, + createTitleLevelRequest: true, + }; + const props = { + ...defaultProps, + request: { + ...defaultProps.request, + instanceId: null, + }, + }; + const selectedInstance = { + id: 'selectedInstanceId', + }; + const selectInstanceLabel = 'Select Instance'; + + beforeEach(() => { + RequestForm.mockImplementation(({ + onSubmit, + onSetSelectedInstance, + }) => ( + <> + onSubmit(submitData)} + /> + + + )); + + render( + + ); + + const selectInstanceButton = screen.getByText(selectInstanceLabel); + + fireEvent.click(selectInstanceButton); + }); + + it('should submit form data', () => { + const expectedArg = { + fulfillmentPreference: fulfillmentTypeMap.DELIVERY, + instanceId: selectedInstance.id, + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + deliveryAddressTypeId: submitData.deliveryAddressTypeId, + }; + const requestForm = screen.getByTestId(testIds.requestForm); + + fireEvent.submit(requestForm); + + expect(defaultProps.onSubmit).toHaveBeenCalledWith(expectedArg); + }); + }); + + describe('When patron blocks set to overridden', () => { + const submitData = { + ...basicSubmitData, + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + fulfillmentPreference: fulfillmentTypeMap.DELIVERY, + createTitleLevelRequest: true, + }; + const overridePatronBlocksLabel = 'Override patron blocks'; + + beforeEach(() => { + RequestForm.mockImplementation(({ + onSubmit, + onSetIsPatronBlocksOverridden, + }) => ( + <> + onSubmit(submitData)} + /> + + + )); + + render( + + ); + + const overridePatronBlocksButton = screen.getByText(overridePatronBlocksLabel); + + fireEvent.click(overridePatronBlocksButton); + }); + + it('should submit form data', () => { + const expectedArg = { + fulfillmentPreference: fulfillmentTypeMap.DELIVERY, + instanceId: defaultProps.request.instanceId, + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + deliveryAddressTypeId: submitData.deliveryAddressTypeId, + requestProcessingParameters: { + overrideBlocks: { + patronBlock: {}, + }, + }, + }; + const requestForm = screen.getByTestId(testIds.requestForm); + + fireEvent.submit(requestForm); + + expect(defaultProps.onSubmit).toHaveBeenCalledWith(expectedArg); + }); + }); + + describe('When patron has blocks', () => { + const submitData = { + ...basicSubmitData, + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + fulfillmentPreference: fulfillmentTypeMap.DELIVERY, + createTitleLevelRequest: true, + }; + const props = { + ...defaultProps, + parentResources: { + patronBlocks: { + records: [ + { + requests: true, + expirationDate: new Date().toISOString(), + } + ], + }, + automatedPatronBlocks: { + records: [ + { + blockRequests: {}, + message: 'block message', + }, + { + message: 'block message 2', + } + ], + }, + }, + }; + + beforeEach(() => { + RequestForm.mockImplementation(({ + onSubmit, + }) => ( + onSubmit(submitData)} + /> + )); + + render( + + ); + }); + + it('should not submit form data', () => { + const requestForm = screen.getByTestId(testIds.requestForm); + + fireEvent.submit(requestForm); + + expect(defaultProps.onSubmit).not.toHaveBeenCalled(); + }); + }); + + describe('When hold shelf expiration date is presented', () => { + const holdShelfExpirationTime = new Date().toTimeString(); + const submitData = { + ...basicSubmitData, + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + fulfillmentPreference: fulfillmentTypeMap.DELIVERY, + createTitleLevelRequest: true, + holdShelfExpirationDate: new Date().toDateString(), + holdShelfExpirationTime, + }; + + beforeEach(() => { + RequestForm.mockImplementation(({ + onSubmit, + }) => ( + onSubmit(submitData)} + /> + )); + + render( + + ); + }); + + it('should submit form data', () => { + const expectedArg = { + fulfillmentPreference: fulfillmentTypeMap.DELIVERY, + instanceId: defaultProps.request.instanceId, + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + deliveryAddressTypeId: submitData.deliveryAddressTypeId, + holdShelfExpirationTime, + holdShelfExpirationDate: expect.any(String), + }; + const requestForm = screen.getByTestId(testIds.requestForm); + + fireEvent.submit(requestForm); + + expect(defaultProps.onSubmit).toHaveBeenCalledWith(expectedArg); + }); + }); + }); +}); diff --git a/src/deprecated/components/ViewRequest/ViewRequest.js b/src/deprecated/components/ViewRequest/ViewRequest.js new file mode 100644 index 00000000..326baffd --- /dev/null +++ b/src/deprecated/components/ViewRequest/ViewRequest.js @@ -0,0 +1,865 @@ +import { + get, + isEqual, + keyBy, +} from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import queryString from 'query-string'; +import { + FormattedMessage, + FormattedDate, + FormattedTime, + injectIntl, +} from 'react-intl'; +import moment from 'moment-timezone'; + +import { + IfPermission, + IntlConsumer, + TitleManager, +} from '@folio/stripes/core'; +import { + Button, + Accordion, + AccordionSet, + AccordionStatus, + Col, + Callout, + Icon, + PaneHeaderIconButton, + KeyValue, + Layer, + Pane, + PaneMenu, + Row, + NoValue, +} from '@folio/stripes/components'; +import { + ViewMetaData, + withTags, + NotesSmartAccordion, +} from '@folio/stripes/smart-components'; + +import ViewRequestShortcutsWrapper from '../../../components/ViewRequestShortcutsWrapper'; +import CancelRequestDialog from '../../../CancelRequestDialog'; +import ItemDetail from '../../../ItemDetail'; +import TitleInformation from '../../../components/TitleInformation'; +import UserDetail from '../../../UserDetail'; +import RequestFormContainer from '../RequestFormContainer/RequestFormContainer'; +import PositionLink from '../../../PositionLink'; +import MoveRequestManager from '../MoveRequestManager/MoveRequestManager'; +import { + requestStatuses, + REQUEST_LEVEL_TYPES, + requestTypesTranslations, + requestStatusesTranslations, + REQUEST_LAYERS, +} from '../../../constants'; +import { + toUserAddress, + isDelivery, + getFullName, + generateUserName, + isValidRequest, + isVirtualItem, + isVirtualPatron, + getRequestErrorMessage, +} from '../../../utils'; +import { getTlrSettings } from '../../utils'; +import urls from '../../../routes/urls'; + +const CREATE_SUCCESS = 'CREATE_SUCCESS'; + +class ViewRequest extends React.Component { + static manifest = { + selectedRequest: { + type: 'okapi', + path: 'circulation/requests/:{id}', + shouldRefresh: (resource, action, refresh) => { + const { + path, + originatingActionType, + } = action.meta; + + if (originatingActionType.includes(CREATE_SUCCESS)) { + return false; + } + + return refresh || (path && path.match(/link/)); + }, + throwErrors: false, + }, + }; + + static propTypes = { + editLink: PropTypes.string, + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired, + search: PropTypes.string, + }).isRequired, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }).isRequired, + joinRequest: PropTypes.func.isRequired, + findResource: PropTypes.func.isRequired, + mutator: PropTypes.shape({ + selectedRequest: PropTypes.shape({ + PUT: PropTypes.func, + }), + }).isRequired, + onClose: PropTypes.func.isRequired, + onCloseEdit: PropTypes.func.isRequired, + onEdit: PropTypes.func, + onDuplicate: PropTypes.func, + buildRecordsForHoldsShelfReport: PropTypes.func.isRequired, + optionLists: PropTypes.object, + tagsToggle: PropTypes.func, + paneWidth: PropTypes.string, + patronGroups: PropTypes.arrayOf(PropTypes.object), + parentMutator: PropTypes.object, + parentResources: PropTypes.shape({ + configs: PropTypes.object.isRequired, + }).isRequired, + resources: PropTypes.shape({ + selectedRequest: PropTypes.shape({ + hasLoaded: PropTypes.bool.isRequired, + other: PropTypes.shape({ + totalRecords: PropTypes.number, + }), + records: PropTypes.arrayOf(PropTypes.object), + }), + }), + query: PropTypes.object, + stripes: PropTypes.shape({ + hasPerm: PropTypes.func.isRequired, + connect: PropTypes.func.isRequired, + logger: PropTypes.shape({ + log: PropTypes.func.isRequired, + }).isRequired, + }).isRequired, + intl: PropTypes.object, + tagsEnabled: PropTypes.bool, + match: PropTypes.object, + }; + + static defaultProps = { + editLink: '', + paneWidth: '50%', + onEdit: () => { }, + }; + + constructor(props) { + super(props); + + const { titleLevelRequestsFeatureEnabled } = getTlrSettings(props.parentResources.configs.records[0]?.value); + + this.state = { + request: {}, + moveRequest: false, + titleLevelRequestsFeatureEnabled, + }; + + const { stripes: { connect } } = props; + + this.cViewMetaData = connect(ViewMetaData); + this.connectedCancelRequestDialog = connect(CancelRequestDialog); + this.cancelRequest = this.cancelRequest.bind(this); + this.update = this.update.bind(this); + this.callout = React.createRef(); + this.accordionStatusRef = React.createRef(); + } + + componentDidMount() { + const requests = this.props.resources.selectedRequest; + + this._isMounted = true; + if (requests && requests.hasLoaded) { + this.loadFullRequest(requests.records[0]); + } + } + + // Use componentDidUpdate to pull in metadata from the related user and item records + componentDidUpdate(prevProps) { + const prevRQ = prevProps.resources.selectedRequest; + const currentRQ = this.props.resources.selectedRequest; + const prevSettings = prevProps.parentResources.configs.records[0]?.value; + const currentSettings = this.props.parentResources.configs.records[0]?.value; + + // Only update if actually needed (otherwise, this gets called way too often) + if (prevRQ && currentRQ && currentRQ.hasLoaded) { + if ((!isEqual(prevRQ.records[0], currentRQ.records[0]))) { + this.loadFullRequest(currentRQ.records[0]); + } + } + + if (prevSettings !== currentSettings) { + const { titleLevelRequestsFeatureEnabled } = getTlrSettings(currentSettings); + + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ titleLevelRequestsFeatureEnabled }); + } + } + + componentWillUnmount() { + this._isMounted = false; + } + + loadFullRequest(basicRequest) { + return this.props.joinRequest(basicRequest).then(request => { + if (this._isMounted) { + this.setState({ request }); + } + }); + } + + update(record) { + const requestFromProps = this.getRequestFromProps() || {}; + const updatedRecord = { + ...requestFromProps, + ...record, + }; + + // Remove the "enhanced record" fields that aren't part of the request schema (and thus can't) + // be included in the record PUT, or the save will fail + delete updatedRecord.requesterName; + delete updatedRecord.requesterBarcode; + delete updatedRecord.patronGroup; + delete updatedRecord.itemBarcode; + delete updatedRecord.title; + delete updatedRecord.location; + delete updatedRecord.loan; + delete updatedRecord.itemStatus; + delete updatedRecord.titleRequestCount; + delete updatedRecord.itemRequestCount; + delete updatedRecord.numberOfReorderableRequests; + delete updatedRecord.holdShelfExpirationTime; + + this.props.mutator.selectedRequest.PUT(updatedRecord).then(() => { + this.props.onCloseEdit(); + this.callout.current.sendCallout({ + message: ( + + ), + }); + }).catch(() => { + this.callout.current.sendCallout({ + message: , + type: 'error', + }); + }); + } + + cancelRequest(cancellationInfo) { + const { + resources, + mutator, + onCloseEdit, + buildRecordsForHoldsShelfReport, + intl, + } = this.props; + + // Get the initial request data, mix in the cancellation info, PUT, + // and then close cancel/edit modes since cancelled requests can't be edited. + const request = get(resources, ['selectedRequest', 'records', 0], {}); + const cancelledRequest = { + ...request, + ...cancellationInfo, + }; + + + mutator.selectedRequest.PUT(cancelledRequest) + .catch(resp => { + resp.json() + .then(res => { + res.errors.forEach(error => { + this.callout.current.sendCallout({ + message: getRequestErrorMessage(error, intl), + type: 'error', + }); + }); + }); + }) + .finally(() => { + this.setState({ isCancellingRequest: false }); + onCloseEdit(); + buildRecordsForHoldsShelfReport(); + }); + } + + onMove = async (request) => { + const { + history, + location: { search }, + } = this.props; + const { titleLevelRequestsFeatureEnabled } = this.state; + const id = titleLevelRequestsFeatureEnabled ? request.instanceId : request.itemId; + + this.loadFullRequest(request); + + history.push(`${urls.requestQueueView(request.id, id)}${search}`, { afterMove: true }); + } + + closeMoveRequest = () => { + this.setState({ moveRequest: false }); + } + + openMoveRequest = () => { + this.setState({ moveRequest: true }); + } + + onReorderRequest = (request) => { + const { + location: { search }, + history, + } = this.props; + const { titleLevelRequestsFeatureEnabled } = this.state; + const idForHistory = titleLevelRequestsFeatureEnabled ? request.instanceId : request.itemId; + + history.push(`${urls.requestQueueView(request.id, idForHistory)}${search}`, { request }); + } + + getRequestFromProps = () => { + const { + resources: { + selectedRequest, + }, + match: { + params: { + id, + }, + }, + } = this.props; + const currentRequest = selectedRequest?.records || []; + + if (!id || currentRequest.length === 0) return null; + + return currentRequest.find(r => r.id === id); + } + + getRequest() { + const curRequest = this.getRequestFromProps(); + + if (!curRequest) return null; + + return (curRequest.id === this.state.request.id) ? this.state.request : curRequest; + } + + getPickupServicePointName(request) { + if (!request) return ''; + const { optionLists: { servicePoints } } = this.props; + const servicePoint = servicePoints.find(sp => (sp.id === request.pickupServicePointId)); + + return get(servicePoint, ['name'], ''); + } + + renderLayer(request) { + const { + optionLists, + location, + stripes, + onCloseEdit, + findResource, + patronGroups, + parentMutator, + } = this.props; + const { + titleLevelRequestsFeatureEnabled, + } = this.state; + + const query = location.search ? queryString.parse(location.search) : {}; + + if (query.layer === REQUEST_LAYERS.EDIT) { + // The hold shelf expiration date is stored as a single value (e.g., 20201101T23:59:00-0400), + // but it's exposed in the UI as separate date- and time-picker components. + let momentDate; + if (request.holdShelfExpirationDate) { + momentDate = moment.tz(request.holdShelfExpirationDate, this.props.intl.timeZone); + } else { + momentDate = moment(); + } + + return ( + + {intl => ( + + { this.update(record); }} + onCancel={onCloseEdit} + onCancelRequest={this.cancelRequest} + optionLists={optionLists} + patronGroups={patronGroups} + query={this.props.query} + parentMutator={parentMutator} + findResource={findResource} + isTlrEnabledOnEditPage={titleLevelRequestsFeatureEnabled} + /> + + ) } + + ); + } + + return null; + } + + renderDetailMenu(request) { + const { + tagsEnabled, + tagsToggle, + } = this.props; + + const tags = ((request && request.tags) || {}).tagList || []; + + const requestStatus = get(request, ['status'], '-'); + const closedStatuses = [requestStatuses.CANCELLED, requestStatuses.FILLED, requestStatuses.PICKUP_EXPIRED, requestStatuses.UNFILLED]; + const isRequestClosed = closedStatuses.includes(requestStatus); + + return ( + + { + tagsEnabled && + + {ariaLabel => ( + + )} + + } + + ); + } + + renderRequest(request) { + const { + stripes, + patronGroups, + optionLists: { cancellationReasons }, + } = this.props; + const { + isCancellingRequest, + moveRequest, + titleLevelRequestsFeatureEnabled, + } = this.state; + const { + requestLevel, + item, + requester + } = request; + + const getPickupServicePointName = this.getPickupServicePointName(request); + const requestStatus = get(request, ['status'], '-'); + const isRequestClosed = requestStatus.startsWith('Closed'); + const isRequestNotFilled = requestStatus === requestStatuses.NOT_YET_FILLED; + const isRequestOpen = requestStatus.startsWith('Open'); + const cancellationReasonMap = keyBy(cancellationReasons, 'id'); + const isRequestValid = isValidRequest(request); + const isDCBTransaction = isVirtualPatron(requester?.personal?.lastName) || isVirtualItem(request?.instanceId, request?.holdingsRecordId); + + let deliveryAddressDetail; + let selectedDelivery = false; + + if (isDelivery(request)) { + selectedDelivery = true; + const deliveryAddressType = get(request, 'deliveryAddressTypeId', null); + + if (deliveryAddressType) { + const addresses = get(request, 'requester.personal.addresses', []); + const deliveryLocations = keyBy(addresses, 'addressTypeId'); + deliveryAddressDetail = toUserAddress(deliveryLocations[deliveryAddressType]); + } + } + + const holdShelfExpireDate = get(request, 'holdShelfExpirationDate', '') && + [requestStatuses.AWAITING_PICKUP, requestStatuses.PICKUP_EXPIRED].includes(get(request, ['status'], '')) + ? + : '-'; + + const expirationDate = (get(request, 'requestExpirationDate', '')) + ? + : '-'; + + 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 (isRequestClosed) { + if (!isRequestValid || (requestLevel === REQUEST_LEVEL_TYPES.TITLE && !titleLevelRequestsFeatureEnabled) || isDCBTransaction) { + return null; + } + + return ( + + + + ); + } + + return ( + <> + + { + isRequestValid && !isDCBTransaction && + + } + + + { + isRequestValid && !isDCBTransaction && + + + + } + {item && isRequestNotFilled && isRequestValid && !isDCBTransaction && + + + } + {isRequestOpen && isRequestValid && !isDCBTransaction && + + + } + + ); + }; + + const referredRecordData = { + instanceTitle: request.instance.title, + instanceId: request.instanceId, + itemBarcode: request.item?.barcode, + itemId: request.itemId, + holdingsRecordId: request.holdingsRecordId, + requesterName: getFullName(request.requester), + requesterId: request.requester?.id ?? request.requesterId, + requestCreateDate: request.metadata.createdDate, + }; + + const isDuplicatingDisabled = + isRequestClosed && + request.requestLevel === REQUEST_LEVEL_TYPES.TITLE && + !this.state.titleLevelRequestsFeatureEnabled; + const requestTypeMessageKey = requestTypesTranslations[request.requestType]; + const requestTypeMessage = requestTypeMessageKey ? : ; + const requestStatusMessageKey = requestStatusesTranslations[request.status]; + const requestStatusMessage = requestStatusMessageKey ? : ; + + return ( + } + lastMenu={this.renderDetailMenu(request)} + dismissible + {... (showActionMenu ? { actionMenu } : {})} + onClose={this.props.onClose} + > + this.props.onDuplicate(request)} + onEdit={this.props.onEdit} + accordionStatusRef={this.accordionStatusRef} + isDuplicatingDisabled={isDuplicatingDisabled} + isEditingDisabled={isRequestClosed} + stripes={stripes} + > + + + + + } + > + + + + } + > + { item + ? ( + + ) + : ( + + ) + } + + } + > + + + {request.metadata && } + + + + + } + value={requestTypeMessage} + /> + + + } + value={requestStatusMessage} + /> + + + } + value={expirationDate} + /> + + + } + value={holdShelfExpireDate} + /> + + + + + } + value={ + + } + /> + + + } + value={} + /> + + {request.cancellationReasonId && + + } + value={get(cancellationReasonMap[request.cancellationReasonId], 'name', '-')} + /> + } + {request.cancellationAdditionalInformation && + + } + value={request.cancellationAdditionalInformation} + /> + } + + } + value={request.patronComments} + /> + + + + + {message => message} + + } + > + + + } + pathToNoteCreate="/requests/notes/new" + pathToNoteDetails="/requests/notes" + /> + + + this.setState({ isCancellingRequest: false })} + request={request} + stripes={stripes} + /> + + {moveRequest && + } + + + ); + } + + renderSpinner() { + return ( + } + lastMenu={this.renderDetailMenu()} + dismissible + onClose={this.props.onClose} + > +
+ +
+
+ ); + } + + render() { + const request = this.getRequest(); + + const content = request + ? this.renderLayer(request) || this.renderRequest(request) + : this.renderSpinner(); + + return ( + <> + {content} + + + ); + } +} + +export default withTags(injectIntl(ViewRequest)); diff --git a/src/deprecated/components/ViewRequest/ViewRequest.test.js b/src/deprecated/components/ViewRequest/ViewRequest.test.js new file mode 100644 index 00000000..6cec2c27 --- /dev/null +++ b/src/deprecated/components/ViewRequest/ViewRequest.test.js @@ -0,0 +1,493 @@ +import moment from 'moment-timezone'; + +import { + render, + screen, +} from '@folio/jest-config-stripes/testing-library/react'; + +import { + CommandList, + defaultKeyboardShortcuts, +} from '@folio/stripes/components'; + +import ViewRequest from './ViewRequest'; +import RequestForm from '../RequestForm/RequestForm'; +import { + INVALID_REQUEST_HARDCODED_ID, + requestStatuses, + REQUEST_LEVEL_TYPES, + DCB_INSTANCE_ID, + DCB_HOLDINGS_RECORD_ID, +} from '../../../constants'; +import { + duplicateRecordShortcut, + editRecordShortcut, +} from '../../../../test/jest/helpers'; + +jest.mock('../RequestForm/RequestForm', () => jest.fn(() => null)); +jest.mock('../MoveRequestManager/MoveRequestManager', () => jest.fn(() => null)); +jest.mock('../../../ItemDetail', () => jest.fn(() => null)); +jest.mock('../../../UserDetail', () => jest.fn(() => null)); +jest.mock('../../../CancelRequestDialog', () => jest.fn(() => null)); +jest.mock('../../../PositionLink', () => jest.fn(() => null)); +jest.mock('../../../components/TitleInformation', () => jest.fn(() => null)); + +describe('ViewRequest', () => { + const labelIds = { + duplicateRequest: 'ui-requests.actions.duplicateRequest', + cancelRequest: 'ui-requests.cancel.cancelRequest', + edit: 'ui-requests.actions.edit', + moveRequest: 'ui-requests.actions.moveRequest', + reorderQueue: 'ui-requests.actions.reorderQueue', + requestDetailTitle: 'ui-requests.request.detail.title', + }; + const mockedRequest = { + instance: { + title: 'Title', + }, + item: { + barcode: 'barcode', + }, + id: 'testId', + holdShelfExpirationDate: 'Wed Nov 24 2021 14:38:30', + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + status: requestStatuses.CANCELLED, + pickupServicePointId: 'servicePoint', + metadata: { + createdDate: 'createdDate', + }, + }; + const mockedRequestWithDCBUser = { + ...mockedRequest, + requester: { + personal: { + lastName: 'DcbSystem', + } + } + }; + const mockedRequestWithVirtualItem = { + ...mockedRequest, + instanceId: DCB_INSTANCE_ID, + holdingsRecordId: DCB_HOLDINGS_RECORD_ID, + }; + const mockedLocation = { + pathname: 'pathname', + search: null, + }; + const mockedConfig = { + records: [ + { value: '{"titleLevelRequestsFeatureEnabled":true}' }, + ], + }; + const defaultProps = { + location: mockedLocation, + history: { + push: jest.fn(), + }, + joinRequest: jest.fn(() => new Promise((resolve) => { + resolve({ id: 'id' }); + })), + findResource: jest.fn(), + mutator: {}, + onClose: jest.fn(), + onCloseEdit: jest.fn(), + buildRecordsForHoldsShelfReport: jest.fn(), + optionLists: { + cancellationReasons: [ + { id: '1' }, + { id: '2' }, + ], + servicePoints: [ + { id: 'servicePoint' }, + ], + }, + parentResources: { + configs: mockedConfig, + }, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + mockedRequest, + ], + }, + }, + stripes: { + hasPerm: jest.fn(() => true), + connect: jest.fn((component) => component), + logger: { + log: jest.fn(), + }, + }, + match: { + params: { + id: 'testId', + }, + }, + }; + const defaultDCBLendingProps = { + ...defaultProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + mockedRequestWithDCBUser, + ], + }, + } + }; + const defaultDCBBorrowingProps = { + ...defaultProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + mockedRequestWithVirtualItem, + ], + }, + } + }; + const renderViewRequest = (props) => render( + + + + ); + + describe('Non DCB Transactions', () => { + beforeEach(() => { + renderViewRequest(defaultProps); + }); + + afterEach(() => { + RequestForm.mockClear(); + }); + + it('should render request detail title', () => { + expect(screen.getByText(labelIds.requestDetailTitle)).toBeInTheDocument(); + }); + + describe('when work with request editing', () => { + beforeAll(() => { + mockedLocation.search = '?layer=edit'; + }); + + it('should set "createTitleLevelRequest" to false when try to edit existed request', () => { + const expectedResult = { + initialValues : { + requestExpirationDate: null, + holdShelfExpirationDate: mockedRequest.holdShelfExpirationDate, + holdShelfExpirationTime: moment(mockedRequest.holdShelfExpirationDate).format('HH:mm'), + createTitleLevelRequest: false, + ...mockedRequest, + }, + }; + + expect(RequestForm).toHaveBeenCalledWith(expect.objectContaining(expectedResult), {}); + }); + }); + + describe('when not working with request editing', () => { + beforeAll(() => { + mockedLocation.search = null; + }); + + describe('when current request is closed', () => { + describe('request is valid', () => { + describe('TLR in enabled', () => { + beforeAll(() => { + mockedConfig.records[0].value = '{"titleLevelRequestsFeatureEnabled":true}'; + }); + + it('should render "Duplicate" button', () => { + expect(screen.getByText(labelIds.duplicateRequest)).toBeInTheDocument(); + }); + }); + + describe('TLR in disabled', () => { + beforeAll(() => { + mockedConfig.records[0].value = '{"titleLevelRequestsFeatureEnabled":false}'; + }); + + it('should not render "Duplicate" button', () => { + expect(screen.queryByText(labelIds.duplicateRequest)).not.toBeInTheDocument(); + }); + }); + }); + + describe('request is not valid', () => { + const closedInvalidRequest = { + ...mockedRequest, + instanceId: INVALID_REQUEST_HARDCODED_ID, + holdingsRecordId: INVALID_REQUEST_HARDCODED_ID, + }; + const props = { + ...defaultProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [closedInvalidRequest], + }, + }, + }; + + beforeEach(() => { + renderViewRequest(props); + }); + + it('should not render "Duplicate" button', () => { + expect(screen.queryByText(labelIds.duplicateRequest)).not.toBeInTheDocument(); + }); + }); + }); + + describe('when current request is open', () => { + const openValidRequest = { + ...mockedRequest, + status: requestStatuses.NOT_YET_FILLED, + }; + + describe('when request is valid', () => { + const props = { + ...defaultProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [openValidRequest], + }, + }, + }; + + beforeEach(() => { + renderViewRequest(props); + }); + + it('actions menu should show all possible actions', () => { + expect(screen.getByText(labelIds.cancelRequest)).toBeInTheDocument(); + expect(screen.getByText(labelIds.edit)).toBeInTheDocument(); + expect(screen.getByText(labelIds.duplicateRequest)).toBeInTheDocument(); + expect(screen.getByText(labelIds.moveRequest)).toBeInTheDocument(); + expect(screen.getByText(labelIds.reorderQueue)).toBeInTheDocument(); + }); + }); + + describe('when request is invalid', () => { + const props = { + ...defaultProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + { + ...openValidRequest, + instanceId: INVALID_REQUEST_HARDCODED_ID, + holdingsRecordId: INVALID_REQUEST_HARDCODED_ID, + }, + ], + }, + }, + }; + + beforeEach(() => { + renderViewRequest(props); + }); + + it('should render action menu with only "Cancel request" button', () => { + expect(screen.getByText(labelIds.cancelRequest)).toBeInTheDocument(); + expect(screen.queryByText(labelIds.edit)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.duplicateRequest)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.moveRequest)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.reorderQueue)).not.toBeInTheDocument(); + }); + }); + }); + }); + + describe('Keyboard shortcuts', () => { + it('should check permission when duplicating', () => { + duplicateRecordShortcut(document.body); + expect(defaultProps.stripes.hasPerm).toHaveBeenCalled(); + }); + + it('should check permission on edit', () => { + editRecordShortcut(document.body); + expect(defaultProps.stripes.hasPerm).toHaveBeenCalled(); + }); + }); + }); + + describe('DCB Transactions', () => { + afterEach(() => { + RequestForm.mockClear(); + }); + + describe('when virtual patron-DCB Lending flow', () => { + describe('when in request detail', () => { + beforeAll(() => { + mockedLocation.search = null; + }); + + describe("when current lending request status starts with 'Closed'", () => { + const closedStatuses = [requestStatuses.FILLED, requestStatuses.CANCELLED, requestStatuses.PICKUP_EXPIRED, requestStatuses.UNFILLED]; + const closedRequests = closedStatuses.map(cStatus => ({ + ...mockedRequestWithDCBUser, + status: cStatus, + })); + const closedRequestsProps = closedRequests.map(cReq => ({ + ...defaultDCBLendingProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + { + ...defaultDCBLendingProps.resources.selectedRequest.records, + ...cReq, + }, + ], + }, + } + })); + + closedRequestsProps.forEach(props => { + it(`should not render action menu when request status is ${props?.resources?.selectedRequest?.records[0]?.status}`, () => { + renderViewRequest(props); + expect(screen.queryByRole('button', { name: 'Actions' })).toBeNull(); + }); + }); + }); + + describe('when current lending request is open', () => { + const openValidRequest = { + ...mockedRequestWithDCBUser, + status: requestStatuses.NOT_YET_FILLED, + }; + const props = { + ...defaultDCBLendingProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + { + ...defaultDCBLendingProps.resources.selectedRequest.records, + ...openValidRequest, + }, + ], + }, + }, + }; + + beforeEach(() => { + renderViewRequest(props); + }); + + it('should render action menu with only "Cancel request" button', () => { + expect(screen.getByText(labelIds.cancelRequest)).toBeInTheDocument(); + expect(screen.queryByText(labelIds.edit)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.duplicateRequest)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.moveRequest)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.reorderQueue)).not.toBeInTheDocument(); + }); + }); + }); + + describe('Keyboard shortcuts', () => { + beforeEach(() => { + renderViewRequest(defaultDCBLendingProps); + }); + it('should check permission when duplicating', () => { + duplicateRecordShortcut(document.body); + expect(defaultProps.stripes.hasPerm).toHaveBeenCalled(); + }); + + it('should check permission on edit', () => { + editRecordShortcut(document.body); + expect(defaultProps.stripes.hasPerm).toHaveBeenCalled(); + }); + }); + }); + + describe('when virtual item-DCB Borrowing flow', () => { + describe('when in request detail', () => { + beforeAll(() => { + mockedLocation.search = null; + }); + + describe('when current borrowing request status starts with "Closed"', () => { + const closedStatuses = [requestStatuses.FILLED, requestStatuses.CANCELLED, requestStatuses.PICKUP_EXPIRED, requestStatuses.UNFILLED]; + const closedRequests = closedStatuses.map(cStatus => ({ + ...mockedRequestWithDCBUser, + status: cStatus, + })); + const closedRequestsProps = closedRequests.map(cReq => ({ + ...defaultDCBBorrowingProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + { + ...defaultDCBBorrowingProps.resources.selectedRequest.records, + ...cReq, + }, + ], + }, + } + })); + + closedRequestsProps.forEach(props => { + it(`should not render action menu when request status is ${props?.resources?.selectedRequest?.records[0]?.status}`, () => { + renderViewRequest(props); + expect(screen.queryByRole('button', { name: 'Actions' })).toBeNull(); + }); + }); + }); + + describe('when current borrowing request is open', () => { + const openValidRequest = { + ...mockedRequestWithDCBUser, + status: requestStatuses.NOT_YET_FILLED, + }; + const props = { + ...defaultDCBBorrowingProps, + resources: { + selectedRequest: { + hasLoaded: true, + records: [ + { + ...defaultDCBBorrowingProps.resources.selectedRequest.records, + ...openValidRequest, + }, + ], + }, + }, + }; + + beforeEach(() => { + renderViewRequest(props); + }); + + it('should render action menu with only "Cancel request" button', () => { + expect(screen.getByText(labelIds.cancelRequest)).toBeInTheDocument(); + expect(screen.queryByText(labelIds.edit)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.duplicateRequest)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.moveRequest)).not.toBeInTheDocument(); + expect(screen.queryByText(labelIds.reorderQueue)).not.toBeInTheDocument(); + }); + }); + }); + + describe('Keyboard shortcuts', () => { + beforeEach(() => { + renderViewRequest(defaultDCBBorrowingProps); + }); + it('should check permission when duplicating', () => { + duplicateRecordShortcut(document.body); + expect(defaultProps.stripes.hasPerm).toHaveBeenCalled(); + }); + + it('should check permission on edit', () => { + editRecordShortcut(document.body); + expect(defaultProps.stripes.hasPerm).toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/deprecated/constants.js b/src/deprecated/constants.js new file mode 100644 index 00000000..74d3a06a --- /dev/null +++ b/src/deprecated/constants.js @@ -0,0 +1,8 @@ +export const RESOURCE_TYPES = { + ITEM: 'item', + INSTANCE: 'instance', + USER: 'user', + HOLDING: 'holding', + REQUEST_TYPES: 'requestTypes', +}; + diff --git a/src/deprecated/routes/RequestQueueRoute/RequestQueueRoute.js b/src/deprecated/routes/RequestQueueRoute/RequestQueueRoute.js new file mode 100644 index 00000000..a08561fb --- /dev/null +++ b/src/deprecated/routes/RequestQueueRoute/RequestQueueRoute.js @@ -0,0 +1,270 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + get, + keyBy, +} from 'lodash'; +import ReactRouterPropTypes from 'react-router-prop-types'; +import { stripesConnect } from '@folio/stripes/core'; + +import RequestQueueView from '../../../views/RequestQueueView'; +import urls from '../../../routes/urls'; +import { requestStatuses } from '../../../constants'; +import { + isPageRequest, +} from '../../../utils'; +import { getTlrSettings } from '../../utils'; + +class RequestQueueRoute extends React.Component { + static getRequest(props) { + const { + location, + resources, + } = props; + + return get(location, 'state.request') || + get(resources, 'request.records[0]'); + } + + static manifest = { + configs: { + type: 'okapi', + records: 'configs', + path: 'configurations/entries', + params: { + query: '(module==SETTINGS and configName==TLR)', + }, + }, + addressTypes: { + type: 'okapi', + path: 'addresstypes', + records: 'addressTypes', + }, + request: { + type: 'okapi', + path: 'circulation/requests', + records: 'requests', + resourceShouldRefresh: true, + params: (_q, _p, _r, _l, props) => { + const request = RequestQueueRoute.getRequest(props); + + return (!request) ? { query: `id==${props.match.params.requestId}` } : null; + }, + }, + holdings: { + type: 'okapi', + records: 'holdingsRecords', + path: 'holdings-storage/holdings', + params: (_q, _p, _r, _l, props) => { + const request = RequestQueueRoute.getRequest(props); + const holdingsRecordId = get(request, 'holdingsRecordId'); + + return (holdingsRecordId) ? { query: `id==${holdingsRecordId}` } : null; + }, + }, + items: { + type: 'okapi', + records: 'items', + path: 'inventory/items', + params: { + query: 'id==:{itemId}', + }, + }, + requests: { + type: 'okapi', + path: 'circulation/requests', + records: 'requests', + accumulate: true, + fetch: false, + shouldRefresh: () => false, + }, + reorderInstanceQueue: { + type: 'okapi', + POST: { + path: 'circulation/requests/queue/instance/:{id}/reorder', + }, + fetch: false, + clientGeneratePk: false, + throwErrors: false, + }, + reorderItemQueue: { + type: 'okapi', + POST: { + path: 'circulation/requests/queue/item/:{id}/reorder', + }, + fetch: false, + clientGeneratePk: false, + throwErrors: false, + }, + }; + + static propTypes = { + location: ReactRouterPropTypes.location, + resources: PropTypes.shape({ + items: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object), + }), + request: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object), + }), + requests: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object), + }).isRequired, + configs: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object).isRequired, + hasLoaded: PropTypes.bool.isRequired, + }).isRequired, + }), + match: PropTypes.object.isRequired, + mutator: PropTypes.shape({ + reorderInstanceQueue: PropTypes.object.isRequired, + reorderItemQueue: PropTypes.object.isRequired, + requests: PropTypes.object.isRequired, + }), + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + goBack: PropTypes.func.isRequired, + }).isRequired, + }; + + componentDidMount() { + this.setTlrSettings(); + } + + componentDidUpdate(prevProps) { + const { configs: prevConfigs } = prevProps.resources; + const { configs } = this.props.resources; + + if ((prevConfigs.hasLoaded !== configs.hasLoaded && configs.hasLoaded)) { + this.setTlrSettings(); + } + } + + setTlrSettings = () => { + const { configs } = this.props.resources; + const { titleLevelRequestsFeatureEnabled } = getTlrSettings(configs.records[0]?.value); + + this.setState({ titleLevelRequestsFeatureEnabled }, this.getRequests); + } + + getRequests = () => { + const { + mutator: { requests }, + resources: { + configs, + }, + match: { + params, + }, + } = this.props; + const { titleLevelRequestsFeatureEnabled } = this.state; + + if (!configs.hasLoaded) { + return; + } + + const path = `circulation/requests/queue/${titleLevelRequestsFeatureEnabled ? 'instance' : 'item'}/${params.id}`; + + requests.reset(); + requests.GET({ path }); + } + + getRequest = () => { + return RequestQueueRoute.getRequest(this.props); + } + + handleClose = () => { + const { + history, + location, + } = this.props; + + if (get(location, 'state.request')) { + history.goBack(); + } else { + const request = this.getRequest(); + history.push(urls.requestView(request.id)); + } + } + + reorder = (requests) => { + const { + mutator: { + reorderInstanceQueue, + reorderItemQueue, + }, + } = this.props; + const { titleLevelRequestsFeatureEnabled } = this.state; + const reorderedQueue = requests.map(({ id, position: newPosition }) => ({ + id, + newPosition, + })); + + return titleLevelRequestsFeatureEnabled + ? reorderInstanceQueue.POST({ reorderedQueue }) + : reorderItemQueue.POST({ reorderedQueue }); + } + + isLoading = () => { + const { resources } = this.props; + + return get(resources, 'requests.isPending', true); + } + + getRequestsWithDeliveryTypes() { + const { resources } = this.props; + const requests = get(resources, 'requests.records', []); + const addressTypes = get(resources, 'addressTypes.records', []); + const addressTypeMap = keyBy(addressTypes, 'id'); + + return requests.map(r => ({ + ...r, + deliveryType: get(addressTypeMap[r.deliveryAddressTypeId], 'addressType'), + })); + } + + render() { + const titleLevelRequestsFeatureEnabled = !!this.state?.titleLevelRequestsFeatureEnabled; + const { resources, location } = this.props; + const request = this.getRequest(); + const requests = this.getRequestsWithDeliveryTypes(); + const notYetFilledRequests = []; + let inProgressRequests = []; + const pageRequests = []; + + requests + .forEach((r) => { + if (isPageRequest(r)) { + pageRequests.push(r); + } else if (r.status === requestStatuses.NOT_YET_FILLED) { + notYetFilledRequests.push(r); + } else { + inProgressRequests.push(r); + } + }); + + inProgressRequests = [ + ...inProgressRequests, + ...pageRequests, // page requests should be shown at the bottom of the accordion + ]; + + return ( + + ); + } +} + +export default stripesConnect(RequestQueueRoute); diff --git a/src/deprecated/routes/RequestQueueRoute/RequestQueueRoute.test.js b/src/deprecated/routes/RequestQueueRoute/RequestQueueRoute.test.js new file mode 100644 index 00000000..68132f77 --- /dev/null +++ b/src/deprecated/routes/RequestQueueRoute/RequestQueueRoute.test.js @@ -0,0 +1,188 @@ +import { render } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + +import RequestQueueRoute from './RequestQueueRoute'; + +jest.mock('react-router-prop-types', () => ({ + location: jest.fn(), +})); +jest.mock('../../../views/RequestQueueView', () => { + return function MockRequestQueueView({ onClose }) { + return ( +
+

Mock RequestQueueView

+ +
+ ); + }; +}); +jest.mock('../../../utils', () => ({ + isPageRequest: jest.fn(), +})); +jest.mock('../../utils', () => ({ + getTlrSettings: jest.fn(() => ({ titleLevelRequestsFeatureEnabled: false })), +})); + +const baseMockResources = { + configs: { + records: [ + { + value: {}, + }, + ], + hasLoaded: true, + }, + request: { + records: [ + { + id: '1', + }, + ], + }, + requests: { + records: [ + { + id: '2', + }, + ], + }, +}; +const mockResources = { + request: { + records: [{ + id: '1', + holdingsRecordId: '2', + itemId: '3', + }], + }, + holdings: { + records: [{ + id: '123', + callNumber: 'ABC123', + }] + }, + items: { + records: [{ + id: '3', + }], + }, + configs: { + records: [{ + value: { + titleLevelRequestsFeatureEnabled: true, + }, + }], + hasLoaded: true, + }, + requests: { + records: [{ + shouldRefresh: false, + }], + }, +}; +const baseMockMutator = { + reorderInstanceQueue: {}, + reorderItemQueue: {}, + requests: { + reset: jest.fn(), + GET: jest.fn(), + }, +}; +const mockMutator = { + ...baseMockMutator, + reorderInstanceQueue: { + POST: jest.fn(), + }, + reorderItemQueue: { + POST: jest.fn(), + }, +}; +const mockHistory = { + push: jest.fn(), + goBack: jest.fn(), +}; +const mockMatch = { + params: { + id: '2', + requestId: '1', + }, +}; +const mockLocation = { + state: { + request: { + id: '1', + }, + }, +}; + +describe('RequestQueueRoute', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call requests.GET when component rerender', () => { + const { rerender } = render( + + ); + + rerender( + + ); + + expect(mockMutator.requests.GET).toHaveBeenCalled(); + }); + + it('should call "history.goBack" if "location.state.request" is truthy', async () => { + const goBack = jest.fn(); + const { getByText } = render( + , + ); + + await userEvent.click(getByText('Close')); + + expect(goBack).toHaveBeenCalledTimes(1); + }); + + it('should call "history.push" with the correct URL if "location.state.request" is falsy', async () => { + const push = jest.fn(); + const { getByText } = render( + , + ); + + await userEvent.click(getByText('Close')); + + expect(push).toHaveBeenCalledWith('/requests/view/1'); + }); +}); diff --git a/src/deprecated/routes/RequestsRoute/RequestsRoute.js b/src/deprecated/routes/RequestsRoute/RequestsRoute.js new file mode 100644 index 00000000..8f25e6f3 --- /dev/null +++ b/src/deprecated/routes/RequestsRoute/RequestsRoute.js @@ -0,0 +1,1720 @@ +import { + get, + isEmpty, + isArray, + size, +} from 'lodash'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { + stringify, + parse, +} from 'query-string'; +import moment from 'moment-timezone'; +import { + FormattedMessage, + injectIntl, +} from 'react-intl'; +import DOMPurify from 'dompurify'; + +import { + AppIcon, + stripesConnect, + IfPermission, + CalloutContext, + TitleManager, +} from '@folio/stripes/core'; +import { + Button, + Checkbox, + filters2cql, + FormattedTime, + MenuSection, + TextLink, + DefaultMCLRowFormatter, + NoValue, + MCLPagingTypes, +} from '@folio/stripes/components'; +import { + deparseFilters, + makeQueryFunction, + SearchAndSort, +} from '@folio/stripes/smart-components'; +import { + exportCsv, + effectiveCallNumber, + getHeaderWithCredentials, +} from '@folio/stripes/util'; + +import ViewRequest from '../../components/ViewRequest/ViewRequest'; +import RequestFormContainer from '../../components/RequestFormContainer/RequestFormContainer'; + +import { + reportHeaders, + fulfillmentTypes, + expiredHoldsReportHeaders, + SLIPS_TYPE, + createModes, + requestStatusesTranslations, + requestTypesTranslations, + REQUEST_LEVEL_TYPES, + DEFAULT_DISPLAYED_YEARS_AMOUNT, + MAX_RECORDS, + OPEN_REQUESTS_STATUSES, + fulfillmentTypeMap, + DEFAULT_REQUEST_TYPE_VALUE, + INPUT_REQUEST_SEARCH_SELECTOR, + PRINT_DETAILS_COLUMNS, + requestFilterTypes, +} from '../../../constants'; +import { + buildUrl, + getFullName, + duplicateRequest, + convertToSlipData, + getInstanceQueryString, + isDuplicateMode, + generateUserName, + getRequestErrorMessage, + getSelectedSlipDataMulti, + selectedRowsNonPrintable, + getNextSelectedRowsState, +} from '../../../utils'; +import { getTlrSettings } from '../../utils'; +import packageInfo from '../../../../package'; +import CheckboxColumn from '../../../components/CheckboxColumn'; + +import { + PrintButton, + PrintContent, + ErrorModal, + LoadingButton, +} from '../../../components'; + +import { + RequestsFilters, + RequestsFiltersConfig, +} from '../../../components/RequestsFilters'; +import RequestsRouteShortcutsWrapper from '../../../components/RequestsRouteShortcutsWrapper'; +import { + isReorderableRequest, + getFormattedYears, + getStatusQuery, + getFullNameForCsvRecords, + updateQuerySortString, +} from '../../../routes/utils'; +import SinglePrintButtonForPickSlip from '../../../components/SinglePrintButtonForPickSlip'; + +const INITIAL_RESULT_COUNT = 30; +const RESULT_COUNT_INCREMENT = 30; +export const DEFAULT_FORMATTER_VALUE = ''; + +export const getPrintHoldRequestsEnabled = (printHoldRequests) => { + const value = printHoldRequests.records[0]?.value; + const { + printHoldRequestsEnabled = false, + } = value ? JSON.parse(value) : {}; + + return printHoldRequestsEnabled; +}; + +export const getFilteredColumnHeadersMap = (columnHeaders) => ( + columnHeaders.filter(column => column.value !== PRINT_DETAILS_COLUMNS.COPIES && + column.value !== PRINT_DETAILS_COLUMNS.PRINTED) +); + +export const extractPickSlipRequestIds = (pickSlipsData) => { + return pickSlipsData.map(pickSlip => pickSlip['request.requestID']); +}; + +export const getLastPrintedDetails = (printDetails, intl) => { + const fullName = getFullName(printDetails?.lastPrintRequester); + const formattedDate = intl.formatDate(printDetails?.printEventDate); + const formattedTime = intl.formatTime(printDetails?.printEventDate); + const localizedDateTime = `${formattedDate}${formattedTime ? ', ' : ''}${formattedTime}`; + + return fullName + ' ' + localizedDateTime; +}; + +export const urls = { + user: (value, idType) => { + const query = stringify({ query: `(${idType}=="${value}")` }); + return `users?${query}`; + }, + item: (value, idType) => { + let query; + + if (isArray(value)) { + query = `(${value.map((valueItem) => `${idType}=="${valueItem}"`).join(' or ')})`; + } else { + query = `(${idType}=="${value}")`; + } + + query = stringify({ query }); + return `inventory/items?${query}`; + }, + instance: (value) => { + const query = stringify({ query: getInstanceQueryString(value) }); + + return `inventory/instances?${query}`; + }, + loan: (value) => { + const query = stringify({ query: `(itemId=="${value}") and status.name==Open` }); + + return `circulation/loans?${query}`; + }, + requestsForItem: (value) => { + const statusQuery = getStatusQuery(OPEN_REQUESTS_STATUSES); + const query = stringify({ + query: `(itemId=="${value}" and (${statusQuery}))`, + limit: MAX_RECORDS, + }); + + return `circulation/requests?${query}`; + }, + requestsForInstance: (value) => { + const statusQuery = getStatusQuery(OPEN_REQUESTS_STATUSES); + const query = stringify({ + query: `(instanceId=="${value}" and (${statusQuery}))`, + limit: MAX_RECORDS, + }); + + return `circulation/requests?${query}`; + }, + requestPreferences: (value) => { + const query = stringify({ query: `(userId=="${value}")` }); + + return `request-preference-storage/request-preference?${query}`; + }, + holding: (value, idType) => { + const query = stringify({ query: `(${idType}=="${value}")` }); + + return `holdings-storage/holdings?${query}`; + }, + requestTypes: ({ + requesterId, + itemId, + instanceId, + requestId, + operation, + }) => { + if (requestId) { + return `circulation/requests/allowed-service-points?operation=${operation}&requestId=${requestId}`; + } + + let requestUrl = `circulation/requests/allowed-service-points?requesterId=${requesterId}&operation=${operation}`; + + if (itemId) { + requestUrl = `${requestUrl}&itemId=${itemId}`; + } else if (instanceId) { + requestUrl = `${requestUrl}&instanceId=${instanceId}`; + } + + return requestUrl; + }, +}; + +export const getListFormatter = ( + { + getRowURL, + setURL, + }, + { + intl, + selectedRows, + pickSlipsToCheck, + pickSlipsData, + isViewPrintDetailsEnabled, + getPrintContentRef, + pickSlipsPrintTemplate, + toggleRowSelection, + onBeforeGetContentForSinglePrintButton, + onBeforePrintForSinglePrintButton, + onAfterPrintForSinglePrintButton, + } +) => ({ + 'select': rq => ( + ), + 'itemBarcode': rq => (rq.item ? rq.item.barcode : DEFAULT_FORMATTER_VALUE), + 'position': rq => (rq.position || DEFAULT_FORMATTER_VALUE), + 'proxy': rq => (rq.proxy ? getFullName(rq.proxy) : DEFAULT_FORMATTER_VALUE), + 'requestDate': rq => ( + + + + ), + 'requester': rq => (rq.requester ? getFullName(rq.requester) : DEFAULT_FORMATTER_VALUE), + 'singlePrint': rq => { + const singlePrintButtonProps = { + request: rq, + pickSlipsToCheck, + pickSlipsPrintTemplate, + onBeforeGetContentForSinglePrintButton, + pickSlipsData, + getPrintContentRef, + ...(isViewPrintDetailsEnabled && { + onBeforePrintForSinglePrintButton, + onAfterPrintForSinglePrintButton, + }), + }; + return ( + ); + }, + 'requesterBarcode': rq => (rq.requester ? rq.requester.barcode : DEFAULT_FORMATTER_VALUE), + 'requestStatus': rq => (requestStatusesTranslations[rq.status] + ? + : ), + 'type': rq => , + 'title': rq => setURL(rq.id)}>{(rq.instance ? rq.instance.title : DEFAULT_FORMATTER_VALUE)}, + 'year': rq => getFormattedYears(rq.instance?.publication, DEFAULT_DISPLAYED_YEARS_AMOUNT), + 'callNumber': rq => effectiveCallNumber(rq.item), + 'servicePoint': rq => get(rq, 'pickupServicePoint.name', DEFAULT_FORMATTER_VALUE), + 'copies': rq => get(rq, PRINT_DETAILS_COLUMNS.COPIES, DEFAULT_FORMATTER_VALUE), + 'printed': rq => (rq.printDetails ? getLastPrintedDetails(rq.printDetails, intl) : DEFAULT_FORMATTER_VALUE), +}); + +export const buildHoldRecords = (records) => { + return records.map(record => { + if (record.requester) { + const { + firstName, + lastName, + } = record.requester; + + record.requester.name = [lastName, firstName].filter(namePart => namePart).join(', '); + } + + return record; + }); +}; + +class RequestsRoute extends React.Component { + static contextType = CalloutContext; + + static manifest = { + addressTypes: { + type: 'okapi', + path: 'addresstypes', + records: 'addressTypes', + params: { + query: 'cql.allRecords=1 sortby addressType', + limit: MAX_RECORDS, + }, + }, + query: { + initialValue: { sort: 'requestDate' }, + }, + resultCount: { initialValue: INITIAL_RESULT_COUNT }, + resultOffset: { initialValue: 0 }, + records: { + type: 'okapi', + path: 'circulation/requests', + records: 'requests', + resultOffset: '%{resultOffset}', + resultDensity: 'sparse', + perRequest: 100, + throwErrors: false, + GET: { + params: { + query: makeQueryFunction( + 'cql.allRecords=1', + '(requesterId=="%{query.query}" or requester.barcode="%{query.query}*" or instance.title="%{query.query}*" or instanceId="%{query.query}*" or item.barcode=="%{query.query}*" or itemId=="%{query.query}" or itemIsbn="%{query.query}" or searchIndex.callNumberComponents.callNumber=="%{query.query}*" or fullCallNumberIndex=="%{query.query}*")', + { + 'title': 'instance.title', + 'instanceId': 'instanceId', + 'publication': 'instance.publication', + 'itemBarcode': 'item.barcode', + 'callNumber': 'searchIndex.shelvingOrder', + 'type': 'requestType', + 'requester': 'requester.lastName requester.firstName', + 'requestStatus': 'status', + 'servicePoint': 'searchIndex.pickupServicePointName', + 'requesterBarcode': 'requester.barcode', + 'requestDate': 'requestDate', + 'position': 'position/number', + 'proxy': 'proxy', + 'copies': 'printDetails.printCount/number', + 'printed': 'printDetails.printEventDate', + }, + RequestsFiltersConfig, + 2, // do not fetch unless we have a query or a filter + ), + }, + staticFallback: { params: {} }, + }, + }, + reportRecords: { + type: 'okapi', + path: 'circulation/requests', + records: 'requests', + perRequest: 1000, + throwErrors: false, + accumulate: true, + }, + patronGroups: { + type: 'okapi', + path: 'groups', + params: { + query: 'cql.allRecords=1 sortby group', + limit: MAX_RECORDS, + }, + records: 'usergroups', + }, + servicePoints: { + type: 'okapi', + records: 'servicepoints', + path: 'service-points', + params: { + query: 'query=(pickupLocation==true) sortby name', + 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', + path: 'manualblocks?query=userId==%{activeRecord.patronId}', + DELETE: { + path: 'manualblocks/%{activeRecord.blockId}', + }, + }, + automatedPatronBlocks: { + type: 'okapi', + records: 'automatedPatronBlocks', + path: 'automated-patron-blocks/%{activeRecord.patronId}', + }, + activeRecord: {}, + expiredHolds: { + accumulate: 'true', + type: 'okapi', + path: 'circulation/requests-report/expired-holds', + fetch: false, + }, + cancellationReasons: { + type: 'okapi', + path: 'cancellation-reason-storage/cancellation-reasons', + records: 'cancellationReasons', + params: { + query: 'cql.allRecords=1 sortby name', + limit: MAX_RECORDS, + }, + }, + staffSlips: { + type: 'okapi', + records: 'staffSlips', + path: 'staff-slips-storage/staff-slips', + params: { + query: 'cql.allRecords=1 sortby name', + limit: MAX_RECORDS, + }, + + throwErrors: false, + }, + pickSlips: { + type: 'okapi', + records: 'pickSlips', + path: 'circulation/pick-slips/%{currentServicePoint.id}', + throwErrors: false, + }, + searchSlips: { + type: 'okapi', + records: 'searchSlips', + path: 'circulation/search-slips/%{currentServicePoint.id}', + throwErrors: false, + }, + printHoldRequests: { + type: 'okapi', + records: 'configs', + path: 'configurations/entries', + params: { + query: '(module==SETTINGS and configName==PRINT_HOLD_REQUESTS)', + }, + }, + currentServicePoint: {}, + tags: { + throwErrors: false, + type: 'okapi', + path: 'tags', + params: { + query: 'cql.allRecords=1 sortby label', + limit: MAX_RECORDS, + }, + records: 'tags', + }, + proxy: { + type: 'okapi', + records: 'proxiesFor', + path: 'proxiesfor', + accumulate: true, + fetch: false, + }, + configs: { + type: 'okapi', + records: 'configs', + path: 'configurations/entries', + params: { + query: '(module==SETTINGS and configName==TLR)', + }, + }, + circulationSettings: { + throwErrors: false, + type: 'okapi', + records: 'circulationSettings', + path: 'circulation/settings', + params: { + query: '(name=printEventLogFeature)', + }, + }, + savePrintDetails: { + type: 'okapi', + POST: { + path: 'circulation/print-events-entry', + }, + fetch: false, + clientGeneratePk: false, + throwErrors: false, + }, + }; + + static propTypes = { + intl: PropTypes.object, + mutator: PropTypes.shape({ + records: PropTypes.shape({ + GET: PropTypes.func, + POST: PropTypes.func, + }), + reportRecords: PropTypes.shape({ + GET: PropTypes.func, + }), + query: PropTypes.object, + requestCount: PropTypes.shape({ + replace: PropTypes.func, + }), + resultOffset: PropTypes.shape({ + replace: PropTypes.func, + }), + resultCount: PropTypes.shape({ + replace: PropTypes.func, + }), + activeRecord: PropTypes.shape({ + update: PropTypes.func, + }), + expiredHolds: PropTypes.shape({ + GET: PropTypes.func, + reset: PropTypes.func, + }), + patronBlocks: PropTypes.shape({ + DELETE: PropTypes.func, + }), + staffSlips: PropTypes.shape({ + GET: PropTypes.func, + }), + pickSlips: PropTypes.shape({ + GET: PropTypes.func, + }).isRequired, + searchSlips: PropTypes.shape({ + GET: PropTypes.func, + }).isRequired, + currentServicePoint: PropTypes.shape({ + update: PropTypes.func.isRequired, + }).isRequired, + proxy: PropTypes.shape({ + reset: PropTypes.func.isRequired, + GET: PropTypes.func.isRequired, + }).isRequired, + circulationSettings: PropTypes.shape({ + GET: PropTypes.func, + }), + savePrintDetails: PropTypes.shape({ + POST: PropTypes.func, + }), + }).isRequired, + resources: PropTypes.shape({ + addressTypes: PropTypes.shape({ + hasLoaded: PropTypes.bool.isRequired, + records: PropTypes.arrayOf(PropTypes.object), + }), + currentServicePoint: PropTypes.object.isRequired, + query: PropTypes.object, + records: PropTypes.shape({ + hasLoaded: PropTypes.bool.isRequired, + isPending: PropTypes.bool.isRequired, + other: PropTypes.shape({ + totalRecords: PropTypes.number, + }), + records: PropTypes.arrayOf(PropTypes.object), + }), + staffSlips: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object).isRequired, + }), + pickSlips: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object).isRequired, + isPending: PropTypes.bool, + }), + searchSlips: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object).isRequired, + isPending: PropTypes.bool, + }), + configs: PropTypes.shape({ + hasLoaded: PropTypes.bool.isRequired, + records: PropTypes.arrayOf(PropTypes.object).isRequired, + }), + printHoldRequests: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object).isRequired, + }), + circulationSettings: PropTypes.shape({ + records: PropTypes.arrayOf(PropTypes.object), + }), + }).isRequired, + stripes: PropTypes.shape({ + connect: PropTypes.func.isRequired, + logger: PropTypes.shape({ + log: PropTypes.func.isRequired, + }).isRequired, + okapi: PropTypes.shape({ + url: PropTypes.string.isRequired, + tenant: PropTypes.string.isRequired, + }), + store: PropTypes.shape({ + getState: PropTypes.func.isRequired, + }), + user: PropTypes.object.isRequired, + timezone: PropTypes.string.isRequired, + locale: PropTypes.string.isRequired, + }).isRequired, + history: PropTypes.object, + location: PropTypes.shape({ + search: PropTypes.string, + pathname: PropTypes.string, + }).isRequired, + match: PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + + const { + titleLevelRequestsFeatureEnabled = false, + createTitleLevelRequestsByDefault = false, + } = getTlrSettings(props.resources.configs.records[0]?.value); + + this.okapiUrl = props.stripes.okapi.url; + + this.httpHeadersOptions = { + ...getHeaderWithCredentials({ + tenant: this.props.stripes.okapi.tenant, + token: this.props.stripes.store.getState().okapi.token, + }) + }; + + this.getRowURL = this.getRowURL.bind(this); + this.addRequestFields = this.addRequestFields.bind(this); + this.processError = this.processError.bind(this); + this.create = this.create.bind(this); + this.findResource = this.findResource.bind(this); + this.toggleModal = this.toggleModal.bind(this); + this.buildRecords = this.buildRecords.bind(this); + // Map to pass into exportCsv + this.columnHeadersMap = this.getColumnHeaders(reportHeaders); + this.expiredHoldsReportColumnHeaders = this.getColumnHeaders(expiredHoldsReportHeaders); + + this.state = { + csvReportPending: false, + submitting: false, + errorMessage: '', + errorModalData: {}, + servicePointId: '', + requests: [], + selectedId: '', + selectedRows: {}, + titleLevelRequestsFeatureEnabled, + createTitleLevelRequestsByDefault, + isViewPrintDetailsEnabled: false, + }; + + this.pickSlipsPrintContentRef = React.createRef(); + this.searchSlipsPrintContentRef = React.createRef(); + this.paneTitleRef = React.createRef(); + this.printSelectedContentRef = React.createRef(); + } + + static getDerivedStateFromProps(props, state) { + const layer = (props.resources.query || {}).layer; + const newState = {}; + const currViewPrintDetailsSettings = get(props.resources, 'circulationSettings.records[0].value.enablePrintLog') === 'true'; + + if (!layer) { + newState.dupRequest = null; + } + + if (currViewPrintDetailsSettings !== state.isViewPrintDetailsEnabled) { + // Update the `isViewPrintDetailsEnabled` state based on user navigation back to Request App. + newState.isViewPrintDetailsEnabled = currViewPrintDetailsSettings; + } + + return Object.keys(newState).length ? newState : null; + } + + componentDidMount() { + this.setCurrentServicePointId(); + } + + componentDidUpdate(prevProps) { + 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(); + const prevStateServicePointId = get(prevProps.resources.currentServicePoint, 'id'); + const { configs: prevConfigs } = prevProps.resources; + const { resources, location, mutator } = this.props; + const { configs, query } = resources; + const instanceId = parse(location?.search)?.instanceId; + + if (prevExpired.length > 0 && expired.length === 0) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ submitting: false }); + } + + if (expired.length > 0 && !submitting) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ submitting: true }); + expired.forEach(p => { + mutator.activeRecord.update({ blockId: p.id }); + mutator.patronBlocks.DELETE({ id: p.id }); + }); + } + + if (prevStateServicePointId !== currentServicePointId) { + this.setCurrentServicePointId(); + } + + if (configs?.records[0]?.value !== prevConfigs?.records[0]?.value) { + const { + titleLevelRequestsFeatureEnabled = false, + createTitleLevelRequestsByDefault = false, + } = getTlrSettings(configs.records[0]?.value); + + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + titleLevelRequestsFeatureEnabled, + createTitleLevelRequestsByDefault, + }); + } + + if (!query.instanceId && instanceId) { + mutator.query.update({ instanceId }); + } + + if (!resources.records.isPending) { + this.onSearchComplete(resources.records); + } + + if (!isViewPrintDetailsEnabled) { + this.handlePrintDetailsDisabled(); + } + } + + handlePrintDetailsDisabled() { + /** + * The function handles the following actions when `isViewPrintDetailsEnabled` is false: + * + * 1. If `filters` in query includes 'PRINT STATUS' filter: + * - it clears the 'PRINT STATUS' filter from query by invoking `handleFilterChange`. + * + * 2. If `sort` in query includes sort by 'printed' or 'copies' column: + * - it removes sorting by 'printed' or 'copies' from the sort query. If both are being sorted, + * the sorting is updated to 'requestDate' instead. + */ + const { resources: { query }, mutator } = this.props; + const printStatusFilterInQuery = this.getActiveFilters()[requestFilterTypes.PRINT_STATUS]; + + if (printStatusFilterInQuery?.length) { + this.handleFilterChange({ name: requestFilterTypes.PRINT_STATUS, values: [] }); + } + + if (query.sort?.includes('printed') || query.sort?.includes('copies')) { + const sort = updateQuerySortString(query.sort); + mutator.query.update({ sort }); + } + } + + toggleAllRows = () => { + const { resources } = this.props; + const { selectedRows } = this.state; + const toggledRows = resources.records.records.reduce((acc, row) => ( + { + ...acc, + [row.id]: row, + } + ), {}); + const filterSelectedRows = rows => { + Object.keys(toggledRows).forEach(id => { + if (rows[id]) delete rows[id]; + }); + return rows; + }; + + this.setState(({ selectedRows: this.getIsAllRowsSelected() ? filterSelectedRows(selectedRows) : { ...selectedRows, ...toggledRows } })); + }; + + getIsAllRowsSelected = () => { + const { resources } = this.props; + const { selectedRows } = this.state; + + if (resources.records.records.length !== 0) { + return resources.records.records.every(({ id }) => Object.keys(selectedRows).includes(id)); + } else { + return false; + } + }; + + onSearchComplete(records) { + const paneTitleRef = this.paneTitleRef.current; + const resultsCount = get(records, 'other.totalRecords', 0); + + if (!!resultsCount && paneTitleRef) { + paneTitleRef.focus(); + } else { + const searchFieldRef = document.getElementById(INPUT_REQUEST_SEARCH_SELECTOR); + + if (searchFieldRef) { + searchFieldRef.focus(); + } + } + } + + async fetchReportData(mutator, query) { + const { GET, reset } = mutator; + + const limit = 1000; + const data = []; + let offset = 0; + let hasData = true; + + while (hasData) { + try { + reset(); + // eslint-disable-next-line no-await-in-loop + const result = await GET({ params: { query, limit, offset } }); + hasData = result.length; + offset += limit; + if (hasData) { + data.push(...result); + } + } catch (err) { + hasData = false; + } + } + + return data; + } + + // Export function for the CSV search report action + async exportData() { + this.setState({ csvReportPending: true }); + + // Build a custom query for the CSV record export, which has to include + // all search and filter parameters + const queryClauses = []; + let queryString; + + const queryTerm = this.props.resources?.query?.query; + const filterQuery = filters2cql(RequestsFiltersConfig, deparseFilters(this.getActiveFilters())); + + if (queryTerm) { + queryString = `(requesterId=="${queryTerm}" or requester.barcode="${queryTerm}*" or item.title="${queryTerm}*" or item.barcode=="${queryTerm}*" or itemId=="${queryTerm}")`; + queryClauses.push(queryString); + } + if (filterQuery) queryClauses.push(filterQuery); + + queryString = queryClauses.join(' and '); + const records = await this.fetchReportData(this.props.mutator.reportRecords, queryString); + const recordsToCSV = this.buildRecords(records); + + this.columnHeadersMap = this.state.isViewPrintDetailsEnabled ? this.columnHeadersMap : + getFilteredColumnHeadersMap(this.columnHeadersMap); + + exportCsv(recordsToCSV, { + onlyFields: this.columnHeadersMap, + excludeFields: ['id'], + }); + + this.setState({ csvReportPending: false }); + } + + getCurrentServicePointInfo = () => { + const { stripes } = this.props; + + const currentState = stripes.store.getState(); + const id = get(currentState, 'okapi.currentUser.curServicePoint.id'); + const name = get(currentState, 'okapi.currentUser.curServicePoint.name'); + + return { id, name }; + }; + + setCurrentServicePointId = () => { + const { + mutator, + resources, + } = this.props; + const { id } = this.getCurrentServicePointInfo(); + + if (resources.currentServicePoint?.id !== id) { + mutator.currentServicePoint.update({ id }); + } + + this.buildRecordsForHoldsShelfReport(); + }; + + getColumnHeaders = (headers) => { + const { intl: { formatMessage } } = this.props; + + return headers.map(item => ({ + label: formatMessage({ id: `ui-requests.${item}` }), + value: item, + })); + }; + + buildRecords(recordsLoaded) { + const result = JSON.parse(JSON.stringify(recordsLoaded)); // Do not mutate the actual resource + const { formatDate, formatTime } = this.props.intl; + + result.forEach(record => { + const contributorNamesMap = []; + const tagListMap = []; + + if (record.instance.contributorNames && record.instance.contributorNames.length > 0) { + record.instance.contributorNames.forEach(item => { + contributorNamesMap.push(item.name); + }); + } + if (record.tags && record.tags.tagList.length > 0) { + record.tags.tagList.forEach(item => { + tagListMap.push(item); + }); + } + if (record.requester) { + record.requester.name = getFullNameForCsvRecords(record.requester); + } + if (record.printDetails) { + const fullName = getFullNameForCsvRecords(record.printDetails.lastPrintRequester); + const lastPrintedDate = record.printDetails.printEventDate || ''; + const date = lastPrintedDate ? `, ${lastPrintedDate}` : ''; + + record.printDetails.lastPrintedDetails = `${fullName}${date}`; + } + if (record.loan) { + const { dueDate } = record.loan; + record.loan.dueDate = `${formatDate(dueDate)}, ${formatTime(dueDate)}`; + } + if (record.proxy) { + record.proxy.name = getFullNameForCsvRecords(record.proxy); + } + if (record.deliveryAddress) { + const { addressLine1, city, region, postalCode, countryId } = record.deliveryAddress; + record.deliveryAddress = `${addressLine1 || ''} ${city || ''} ${region || ''} ${countryId || ''} ${postalCode || ''}`; + } + record.instance.contributorNames = contributorNamesMap.join('; '); + if (record.tags) record.tags.tagList = tagListMap.join('; '); + }); + + return result; + } + + // idType can be 'id', 'barcode', etc. + findResource(resource, value, idType = 'id') { + const query = urls[resource](value, idType); + + return fetch(`${this.okapiUrl}/${query}`, this.httpHeadersOptions).then(response => response.json()); + } + + toggleModal() { + this.setState({ errorMessage: '' }); + } + + // Called as a map function + addRequestFields(request) { + const { + requesterId, + instanceId, + itemId, + } = request; + const { titleLevelRequestsFeatureEnabled } = this.state; + + return Promise.all( + [ + this.findResource('user', requesterId), + this.findResource('requestsForInstance', instanceId), + ...(itemId + ? [ + this.findResource('requestsForItem', itemId), + ] + : []), + ], + ).then(([users, titleRequests, itemRequests]) => { + // Each element of the promises array returns an array of results, but in + // this case, there should only ever be one result for each. + const requester = get(users, 'users[0]', null); + const titleRequestCount = titleRequests?.requests.filter(r => r.requestLevel === REQUEST_LEVEL_TYPES.TITLE).length || 0; + const dynamicProperties = {}; + const requestsForFilter = titleLevelRequestsFeatureEnabled ? titleRequests.requests : itemRequests.requests; + + dynamicProperties.numberOfReorderableRequests = requestsForFilter.filter(currentRequest => isReorderableRequest(currentRequest)).length; + + if (itemId) { + dynamicProperties.itemRequestCount = get(itemRequests, 'totalRecords', 0); + } + + return { + ...request, + requester, + titleRequestCount, + ...dynamicProperties, + }; + }); + } + + getRowURL(id) { + const { + match: { path }, + location: { search }, + } = this.props; + + return `${path}/view/${id}${search}`; + } + + setURL = (id) => { + this.setState({ + selectedId: id, + }); + } + + resultIsSelected = ({ item }) => item.id === this.state.selectedId; + + viewRecordOnCollapse = () => { + this.setState({ + selectedId: null, + }); + } + + getHelperResourcePath = (helper, id) => `circulation/requests/${id}`; + + massageNewRecord = (requestData) => { + const { intl: { timeZone } } = this.props; + const isoDate = moment.tz(timeZone).toISOString(); + Object.assign(requestData, { requestDate: isoDate }); + }; + + renderPaneSub() { + const selectedRowsCount = size(this.state.selectedRows); + + return selectedRowsCount + ? ( + + ) + : null; + } + + onChangePatron = (patron) => { + this.props.mutator.activeRecord.update({ patronId: patron.id }); + }; + + create = (data) => { + const query = new URLSearchParams(this.props.location.search); + const mode = query.get('mode'); + + return this.props.mutator.records.POST(data) + .then(() => { + this.closeLayer(); + + this.context.sendCallout({ + message: isDuplicateMode(mode) + ? ( + + ) + : ( + + ), + }); + }) + .catch(resp => { + this.context.sendCallout({ + message: isDuplicateMode(mode) + ? + : , + type: 'error', + }); + + return this.processError(resp); + }); + }; + + processError(resp) { + const contentType = resp.headers.get('Content-Type') || ''; + if (contentType.startsWith('application/json')) { + return resp.json().then(error => this.handleJsonError(error)); + } else { + return resp.text().then(error => this.handleTextError(error)); + } + } + + handleTextError(error) { + const item = { barcode: error }; + return { item }; + } + + handleJsonError({ errors }) { + const { + intl, + } = this.props; + const errorMessages = []; + + errors.forEach((error) => ( + errorMessages.push(getRequestErrorMessage(error, intl)) + )); + + this.setState({ errorMessage: errorMessages.join(';') }); + } + + handleCloseNewRecord = (e) => { + if (e) { + e.preventDefault(); + } + + this.closeLayer(); + }; + + closeLayer() { + const url = buildUrl(this.props.location, { + layer: null, + itemBarcode: null, + userBarcode: null, + itemId: null, + instanceId: null, + userId: null, + query: null, + }); + + this.props.history.push(url); + } + + onDuplicate = (request) => { + const dupRequest = duplicateRequest(request); + + const newRequestData = { + layer: 'create', + instanceId: request.instanceId, + userBarcode: request.requester.barcode, + mode: createModes.DUPLICATE, + }; + + if (request.requestLevel === REQUEST_LEVEL_TYPES.ITEM) { + newRequestData.itemBarcode = request.item.barcode; + newRequestData.itemId = request.itemId; + } + if (request.requestLevel === REQUEST_LEVEL_TYPES.TITLE) { + dupRequest.createTitleLevelRequest = true; + } + + this.setState({ dupRequest }); + this.props.mutator.query.update(newRequestData); + }; + + buildRecordsForHoldsShelfReport = async () => { + const { + mutator: { + expiredHolds: { + reset, + GET, + }, + }, + } = this.props; + + this.setState({ + holdsShelfReportPending: true, + }); + + reset(); + + const { id } = this.getCurrentServicePointInfo(); + + this.setState({ + servicePointId: id, + }); + + if (id !== this.state.servicePointId) { + const path = `circulation/requests-reports/hold-shelf-clearance/${id}`; + const { requests } = await GET({ path }); + + this.setState({ + requests, + }); + } + + this.setState({ + holdsShelfReportPending: false, + }); + } + + exportExpiredHoldsToCSV = async () => { + const { + servicePointId, + requests, + } = this.state; + + if (!servicePointId) { + this.setState( + { + errorModalData: { + errorMessage: 'ui-requests.noServicePoint.errorMessage', + label: 'ui-requests.noServicePoint.label', + }, + } + ); + + return; + } + + const recordsToCSV = buildHoldRecords(requests); + exportCsv(recordsToCSV, { + onlyFields: this.expiredHoldsReportColumnHeaders, + excludeFields: ['id'], + }); + }; + + errorModalClose = () => { + this.setState({ errorModalData: {} }); + }; + + getPrintTemplate(slipType) { + const staffSlips = get(this.props.resources, 'staffSlips.records', []); + const slipTypeInLowerCase = slipType.toLowerCase(); + const slipTemplate = staffSlips.find(slip => slip.name.toLowerCase() === slipTypeInLowerCase); + + return DOMPurify.sanitize(get(slipTemplate, 'template', ''), { ADD_TAGS: ['Barcode'] }); + } + + handleFilterChange = ({ name, values }) => { + const { mutator } = this.props; + const newFilters = { + ...this.getActiveFilters(), + [name]: values, + }; + + const filters = Object.keys(newFilters) + .map((filterName) => { + return newFilters[filterName] + .map((filterValue) => `${filterName}.${filterValue}`) + .join(','); + }) + .filter(filter => filter) + .join(','); + + mutator.query.update({ filters }); + }; + + getActiveFilters = () => { + const { query } = this.props.resources; + + if (!query || !query.filters) return {}; + + return query.filters + .split(',') + .reduce((filterMap, currentFilter) => { + const [name, value] = currentFilter.split('.'); + + if (!Array.isArray(filterMap[name])) { + filterMap[name] = []; + } + + filterMap[name].push(value); + + return filterMap; + }, {}); + } + + renderFilters = (onChange) => { + const { resources } = this.props; + const { titleLevelRequestsFeatureEnabled, isViewPrintDetailsEnabled } = this.state; + + return ( + onChange({ name, values: [] })} + titleLevelRequestsFeatureEnabled={titleLevelRequestsFeatureEnabled} + isViewPrintDetailsEnabled={isViewPrintDetailsEnabled} + /> + ); + }; + + savePrintEventDetails = async (requestIds) => { + const currDateTime = new Date(); + const printTimeStamp = currDateTime.toISOString(); + const { id: loggedInUserId, username: loggedInUsername } = this.props.stripes.user.user; + + try { + await this.props.mutator.savePrintDetails.POST({ + 'requestIds': requestIds, + 'requesterName': loggedInUsername, + 'requesterId': loggedInUserId, + 'printEventDate': printTimeStamp + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to save print event details:', error); + } + }; + + onBeforeGetContentForPrintButton = (onToggle) => ( + new Promise(resolve => { + this.context.sendCallout({ message: }); + onToggle(); + // without the timeout the printing process starts right away + // and the callout and onToggle above are blocked + setTimeout(() => resolve(), 1000); + }) + ); + + onBeforeGetContentForSinglePrintButton = () => ( + new Promise(resolve => { + this.context.sendCallout({ message: }); + setTimeout(() => resolve(), 1000); + }) + ); + + onAfterPrintForPrintButton = () => { + if (this.state.isViewPrintDetailsEnabled) { + this.props.mutator.resultOffset.replace(0); + } + } + + printContentRefs = {}; + + getPrintContentRef = (rqId) => { + if (!this.printContentRefs[rqId]) { + this.printContentRefs[rqId] = React.createRef(); + } + + return this.printContentRefs[rqId]; + }; + + toggleRowSelection = row => { + this.setState(({ selectedRows }) => ({ selectedRows: getNextSelectedRowsState(selectedRows, row) })); + }; + + getPageTitle = () => { + const { + location, + intl: { + formatMessage, + }, + } = this.props; + const query = parse(location.search)?.query; + + if (query) { + return formatMessage({ id: 'ui-requests.documentTitle.search' }, { query }); + } + + return formatMessage({ id: 'ui-requests.meta.title' }); + } + + render() { + const { + resources, + mutator, + stripes, + history, + location, + intl, + stripes: { + timezone, + locale, + user: { user } + }, + } = this.props; + + const { + csvReportPending, + dupRequest, + errorMessage, + errorModalData, + requests, + servicePointId, + selectedRows, + holdsShelfReportPending, + createTitleLevelRequestsByDefault, + isViewPrintDetailsEnabled, + } = this.state; + const isPrintHoldRequestsEnabled = getPrintHoldRequestsEnabled(resources.printHoldRequests); + const { name: servicePointName } = this.getCurrentServicePointInfo(); + const pickSlips = get(resources, 'pickSlips.records', []); + const searchSlips = get(resources, 'searchSlips.records', []); + const patronGroups = get(resources, 'patronGroups.records', []); + const addressTypes = get(resources, 'addressTypes.records', []); + const servicePoints = get(resources, 'servicePoints.records', []); + const cancellationReasons = get(resources, 'cancellationReasons.records', []); + const requestCount = get(resources, 'records.other.totalRecords', 0); + const initialValues = dupRequest || + { + requestType: DEFAULT_REQUEST_TYPE_VALUE, + fulfillmentPreference: fulfillmentTypeMap.HOLD_SHELF, + createTitleLevelRequest: createTitleLevelRequestsByDefault, + }; + + const columnLabels = { + select: } + onChange={this.toggleAllRows} + />, + requestDate: , + title: , + year: , + itemBarcode: , + callNumber: , + type: , + requestStatus: , + position: , + servicePoint: , + requester: , + requesterBarcode: , + singlePrint: , + proxy: , + ...(isViewPrintDetailsEnabled && { + copies: , + printed: , + }), + }; + + const isPickSlipsArePending = resources?.pickSlips?.isPending; + const isSearchSlipsArePending = resources?.searchSlips?.isPending; + const requestsEmpty = isEmpty(requests); + const isPickSlipsEmpty = isEmpty(pickSlips); + const isSearchSlipsEmpty = isEmpty(searchSlips); + const pickSlipsPrintTemplate = this.getPrintTemplate(SLIPS_TYPE.PICK_SLIP); + const searchSlipsPrintTemplate = this.getPrintTemplate(SLIPS_TYPE.SEARCH_SLIP_HOLD_REQUESTS); + const pickSlipsData = convertToSlipData(pickSlips, intl, timezone, locale, SLIPS_TYPE.PICK_SLIP, user); + const searchSlipsData = convertToSlipData(searchSlips, intl, timezone, locale, SLIPS_TYPE.SEARCH_SLIP_HOLD_REQUESTS); + let multiSelectPickSlipData = getSelectedSlipDataMulti(pickSlipsData, selectedRows); + + const resultsFormatter = getListFormatter( + { + getRowURL: this.getRowURL, + setURL: this.setURL, + }, + { + intl, + selectedRows, + pickSlipsToCheck: pickSlips, + pickSlipsData, + isViewPrintDetailsEnabled, + getPrintContentRef: this.getPrintContentRef, + pickSlipsPrintTemplate, + toggleRowSelection: this.toggleRowSelection, + onBeforeGetContentForSinglePrintButton: this.onBeforeGetContentForSinglePrintButton, + onBeforePrintForSinglePrintButton: this.savePrintEventDetails, + onAfterPrintForSinglePrintButton: this.onAfterPrintForPrintButton, + } + ); + + const isPrintingDisabled = isPickSlipsEmpty || selectedRowsNonPrintable(pickSlipsData, selectedRows); + + const actionMenu = ({ onToggle, renderColumnsMenu }) => ( + <> + + + + + {csvReportPending ? + + + : + + } + { + isPickSlipsArePending ? + + + : + <> + + this.onBeforeGetContentForPrintButton(onToggle)} + onBeforePrint={async () => { + if (isViewPrintDetailsEnabled) { + const requestIds = extractPickSlipRequestIds(pickSlipsData); + await this.savePrintEventDetails(requestIds); + } + }} + onAfterPrint={this.onAfterPrintForPrintButton} + > + + + new Promise(resolve => { + this.context.sendCallout({ message: }); + onToggle(); + // without the timeout the printing process starts right away + // and the callout and onToggle above are blocked + setTimeout(() => resolve(), 1000); + multiSelectPickSlipData = getSelectedSlipDataMulti(pickSlipsData, selectedRows); + }) + } + onBeforePrint={ + async () => { + if (isViewPrintDetailsEnabled) { + const selectedPickSlips = getSelectedSlipDataMulti(pickSlipsData, selectedRows); + const selectedRequestIds = extractPickSlipRequestIds(selectedPickSlips); + await this.savePrintEventDetails(selectedRequestIds); + } + } + } + onAfterPrint={this.onAfterPrintForPrintButton} + > + + + + } + { + isPrintHoldRequestsEnabled && + <> + { + isSearchSlipsArePending ? + + + : + this.onBeforeGetContentForPrintButton(onToggle)} + > + + + } + + } + + {renderColumnsMenu} + + ); + + const columnManagerProps = { + excludeKeys: ['title', 'select'], + visibleColumns: [ + 'select', + 'title', + 'requestDate', + 'year', + 'itemBarcode', + 'type', + 'requestStatus', + 'position', + 'requester', + 'requesterBarcode', + 'proxy', + ], + }; + const pageTitle = this.getPageTitle(); + + return ( + + <> + { + isEmpty(errorModalData) || + + } + +
+ +
+ + + { + isPrintHoldRequestsEnabled && + + } + +
+ ); + } +} + +export default stripesConnect(injectIntl(RequestsRoute)); diff --git a/src/deprecated/routes/RequestsRoute/RequestsRoute.test.js b/src/deprecated/routes/RequestsRoute/RequestsRoute.test.js new file mode 100644 index 00000000..76fb1d64 --- /dev/null +++ b/src/deprecated/routes/RequestsRoute/RequestsRoute.test.js @@ -0,0 +1,1739 @@ +import React from 'react'; +import { + stringify, +} from 'query-string'; +import { createIntl, createIntlCache } from 'react-intl'; + +import { + render, + screen, + waitFor, + cleanup, + fireEvent, +} from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + +import { + SearchAndSort, +} from '@folio/stripes/smart-components'; +import { + CalloutContext, + AppIcon, + TitleManager, +} from '@folio/stripes/core'; +import { + TextLink, + Checkbox, +} from '@folio/stripes/components'; +import { + exportCsv, + effectiveCallNumber, +} from '@folio/stripes/util'; + +import RequestsRoute, { + buildHoldRecords, + getListFormatter, + getPrintHoldRequestsEnabled, + getLastPrintedDetails, + getFilteredColumnHeadersMap, + urls, + DEFAULT_FORMATTER_VALUE, +} from './RequestsRoute'; +import CheckboxColumn from '../../../components/CheckboxColumn'; +import { + duplicateRequest, + getFullName, + getInstanceQueryString, + getNextSelectedRowsState, +} from '../../../utils'; +import { getTlrSettings } from '../../utils'; +import { + getFormattedYears, + getStatusQuery, +} from '../../../routes/utils'; +import { + createModes, + REQUEST_LEVEL_TYPES, + DEFAULT_REQUEST_TYPE_VALUE, + requestStatusesTranslations, + requestStatuses, + requestTypesTranslations, + requestTypesMap, + DEFAULT_DISPLAYED_YEARS_AMOUNT, + OPEN_REQUESTS_STATUSES, + MAX_RECORDS, + REQUEST_OPERATIONS, + INPUT_REQUEST_SEARCH_SELECTOR, + PRINT_DETAILS_COLUMNS, +} from '../../../constants'; +import { historyData } from '../../../../test/jest/fixtures/historyData'; + +const createRefMock = { + current: { + focus: jest.fn(), + }, +}; +const createDocumentRefMock = { + focus: jest.fn(), +}; +const testIds = { + searchAndSort: 'searchAndSort', + pickSlipsPrintTemplate: 'pickSlipsPrintTemplate', + searchSlipsPrintTemplate: 'searchSlipsPrintTemplate', + singlePrintButton: 'singlePrintButton', + rowCheckbox: 'rowCheckbox', + selectRequestCheckbox: 'selectRequestCheckbox', +}; + +const intlCache = createIntlCache(); +const intl = createIntl( + { + locale: 'en-US', + messages: {}, + }, + intlCache +); + +jest.spyOn(React, 'createRef').mockReturnValue(createRefMock); +jest.spyOn(document, 'getElementById').mockReturnValue(createDocumentRefMock); + +jest.mock('query-string', () => ({ + ...jest.requireActual('query-string'), + stringify: jest.fn(), +})); +jest.mock('../../../utils', () => ({ + ...jest.requireActual('../../../utils'), + duplicateRequest: jest.fn((request) => request), + getFullName: jest.fn(), + getFormattedYears: jest.fn(), + getInstanceQueryString: jest.fn(), + getNextSelectedRowsState: jest.fn(), + extractPickSlipRequestIds: jest.fn(), +})); +jest.mock('../../utils', () => ({ + getTlrSettings: jest.fn(() => ({})), +})); +jest.mock('../../../routes/utils', () => ({ + ...jest.requireActual('../../../routes/utils'), + getFormattedYears: jest.fn(), + getStatusQuery: jest.fn(), +})); +jest.mock('../../../components', () => ({ + ErrorModal: jest.fn(({ onClose }) => ( +
+ ErrorModal + +
+ )), + LoadingButton: jest.fn(() => null), + PrintButton: jest.fn(({ + onBeforeGetContent, + onBeforePrint, + onAfterPrint, + children, + }) => { + const handleClick = () => { + Promise.resolve(onBeforeGetContent()); + Promise.resolve(onBeforePrint()); + Promise.resolve(onAfterPrint()); + }; + return ( +
+ + {children} +
+ ); + }), + PrintContent: jest.fn(({ printContentTestId }) =>
PrintContent
) +})); +jest.mock('../../../components/RequestsFilters/RequestsFilters', () => ({ onClear }) => { + return ( +
+ RequestsFilter + +
+ ); +}); +jest.mock('../../components/ViewRequest/ViewRequest', () => jest.fn()); +jest.mock('../../components/RequestForm/RequestForm', () => jest.fn()); +jest.mock('../../components/RequestFormContainer/RequestFormContainer', () => jest.fn()); +jest.mock('../../../components/SinglePrintButtonForPickSlip', () => jest.fn(({ + onBeforeGetContentForSinglePrintButton, + onBeforePrintForSinglePrintButton, + onAfterPrintForSinglePrintButton, +}) => { + const handleClick = () => { + onBeforeGetContentForSinglePrintButton(); + onBeforePrintForSinglePrintButton(['reqId']); + onAfterPrintForSinglePrintButton(); + }; + return ( + + ); +})); +jest.mock('../../../components/CheckboxColumn', () => jest.fn(({ + toggleRowSelection, +}) => ( + +))); + +global.fetch = jest.fn(() => Promise.resolve({ + json: () => Promise.resolve({ + requests: [ + { + requestLevel: REQUEST_LEVEL_TYPES.TITLE, + } + ], + }), +})); + +const RequestFilterData = { + onChange: jest.fn(), +}; +const request = { + requesterId: 'requestId', + instanceId: 'instanceId', + itemId: 'itemId', +}; +const labelIds = { + closedCancelledRequest: requestStatusesTranslations[requestStatuses.CANCELLED], + requestType: requestTypesTranslations[requestTypesMap.RECALL], + printPickSlips: 'ui-requests.printPickSlips', + printSearchSlips: 'ui-requests.printSearchSlips', + titleWithSearch: 'ui-requests.documentTitle.search', + defaultTitle: 'ui-requests.meta.title', + recordsSelected: 'ui-requests.rows.recordsSelected', +}; +const mockedRequest = { + requestLevel: REQUEST_LEVEL_TYPES.ITEM, + itemId: 'itemId', + instanceId: 'instanceId', + item: { + barcode: 'itemBarcode', + }, + requester: { + barcode: 'requesterBarcode', + }, + id: 'requestId', +}; +const printDetailsMockData = { + printCount: 11, + printEventDate: '2024-08-03T13:33:31.868Z', + lastPrintRequester: { firstName: 'firstName', middleName: 'middleName', lastName: 'lastName' }, +}; + +SearchAndSort.mockImplementation(jest.fn(({ + paneTitleRef, + actionMenu, + detailProps: { + onDuplicate, + buildRecordsForHoldsShelfReport, + onChangePatron, + joinRequest, + }, + getHelperResourcePath, + massageNewRecord, + onCloseNewRecord, + onFilterChange, + parentResources, + renderFilters, + resultIsSelected, + viewRecordOnCollapse, + customPaneSub, + columnMapping, + resultsFormatter, +}) => { + const onClickActions = () => { + onDuplicate(parentResources.records.records[0]); + buildRecordsForHoldsShelfReport(); + massageNewRecord({}); + resultIsSelected({ + item: { + id: 'id', + }, + }); + viewRecordOnCollapse(); + }; + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
+ {customPaneSub} +
+