From 165c15c86e883d84288474659b6febc40ab015e7 Mon Sep 17 00:00:00 2001 From: Jason Skomorowski Date: Mon, 28 Sep 2026 19:18:06 -0400 Subject: [PATCH] Rerequest ILLDEV-530 --- .../translations/stripes-reshare/en.json | 1 + ui-rs/src/components/Flow/actionMeta.js | 1 + .../components/Flow/moreActions/Rerequest.js | 31 ++ .../src/components/Flow/moreActions/index.js | 1 + .../PatronRequestFormPane.js | 74 ++++ .../PatronRequestForm/formMapping.js | 24 +- .../PatronRequestForm/submissionError.js | 21 ++ ui-rs/src/index.js | 4 + ui-rs/src/routes/CreateRoute.js | 77 +--- ui-rs/src/routes/EditRoute.js | 77 +--- ui-rs/src/routes/FlowRoute.test.js | 34 +- ui-rs/src/routes/RerequestRoute.js | 130 +++++++ ui-rs/src/routes/RerequestRoute.test.js | 351 ++++++++++++++++++ ui-rs/translations/ui-rs/en.json | 3 + 14 files changed, 706 insertions(+), 123 deletions(-) create mode 100644 ui-rs/src/components/Flow/moreActions/Rerequest.js create mode 100644 ui-rs/src/components/PatronRequestForm/PatronRequestFormPane.js create mode 100644 ui-rs/src/components/PatronRequestForm/submissionError.js create mode 100644 ui-rs/src/routes/RerequestRoute.js create mode 100644 ui-rs/src/routes/RerequestRoute.test.js diff --git a/stripes-reshare/translations/stripes-reshare/en.json b/stripes-reshare/translations/stripes-reshare/en.json index 46f1b07..ba888e6 100644 --- a/stripes-reshare/translations/stripes-reshare/en.json +++ b/stripes-reshare/translations/stripes-reshare/en.json @@ -29,6 +29,7 @@ "actions.accept-cancel": "Accept cancellation", "actions.accept-retry": "Accept retry", "actions.reject-retry": "Reject retry", + "actions.rerequest": "Create revised request", "actions.recall": "Recall item", "actions.recall.success": "Item recalled", "actions.recall.error": "Error recalling item. {errMsg}", diff --git a/ui-rs/src/components/Flow/actionMeta.js b/ui-rs/src/components/Flow/actionMeta.js index 41deeb3..1e99aa8 100644 --- a/ui-rs/src/components/Flow/actionMeta.js +++ b/ui-rs/src/components/Flow/actionMeta.js @@ -19,6 +19,7 @@ const actionMeta = { 'pullslip-printed': { icon: 'print' }, 'ask-retry': { icon: 'edit' }, 'recall': { icon: 'flag' }, + 'rerequest': { icon: 'duplicate' }, 'overdue': { icon: 'clock' }, 'renew': { icon: 'replace' }, 'accept-renewal': { icon: 'check-circle', primaryOnly: true }, diff --git a/ui-rs/src/components/Flow/moreActions/Rerequest.js b/ui-rs/src/components/Flow/moreActions/Rerequest.js new file mode 100644 index 0000000..05b1666 --- /dev/null +++ b/ui-rs/src/components/Flow/moreActions/Rerequest.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useHistory, useLocation } from 'react-router-dom'; +import { useIsActionPending } from '@projectreshare/stripes-reshare'; +import { Button, Icon } from '@folio/stripes/components'; + +import actionMeta from '../actionMeta'; + +// Opens the revision form; the form posts the action itself on submit. +const Rerequest = ({ request }) => { + const history = useHistory(); + const location = useLocation(); + const actionPending = !!useIsActionPending(request.id); + + const open = () => history.push({ + // Relative: replaces the flow route's last segment. + pathname: 'rerequest', + search: location.search, + state: { direct: true }, + }); + + return ( + + ); +}; + +export default Rerequest; diff --git a/ui-rs/src/components/Flow/moreActions/index.js b/ui-rs/src/components/Flow/moreActions/index.js index 31c6bc9..0371a49 100644 --- a/ui-rs/src/components/Flow/moreActions/index.js +++ b/ui-rs/src/components/Flow/moreActions/index.js @@ -7,6 +7,7 @@ export { default as AddCondition } from './AddCondition'; export { default as AddItem } from './AddItem'; export { default as AskRetry } from './AskRetry'; export { default as Recall } from './Recall'; +export { default as Rerequest } from './Rerequest'; export { default as SupplyDocument } from './SupplyDocument'; export const CannotSupply = props => ( diff --git a/ui-rs/src/components/PatronRequestForm/PatronRequestFormPane.js b/ui-rs/src/components/PatronRequestForm/PatronRequestFormPane.js new file mode 100644 index 0000000..1e162d9 --- /dev/null +++ b/ui-rs/src/components/PatronRequestForm/PatronRequestFormPane.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useForm, useFormState } from 'react-final-form'; +import { Prompt } from 'react-router-dom'; +import { Button, Pane, Paneset, PaneFooter } from '@folio/stripes/components'; + +const PatronRequestFormPane = ({ + titleId, + submitLabelId = titleId, + submitDisabled = false, + onClose, + children, +}) => { + const form = useForm(); + const { pristine, submitting, submitSucceeded } = useFormState({ + subscription: { pristine: true, submitting: true, submitSucceeded: true }, + }); + + // The submit button is in the footer, outside the form element, so both route + // through here. Also applies submitDisabled to an Enter-key submit. + const submit = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (submitting || submitDisabled) return undefined; + return form.submit(); + }; + + return ( + + } + footer={ + + + + } + renderEnd={ + + } + /> + } + > +
+ {children} +
+ + {prompt => } + +
+
+ ); +}; + +export default PatronRequestFormPane; diff --git a/ui-rs/src/components/PatronRequestForm/formMapping.js b/ui-rs/src/components/PatronRequestForm/formMapping.js index 04a8eb6..dee88df 100644 --- a/ui-rs/src/components/PatronRequestForm/formMapping.js +++ b/ui-rs/src/components/PatronRequestForm/formMapping.js @@ -1,3 +1,4 @@ +import omit from 'lodash/omit'; import { CREATE, EDIT } from './operations'; import { ID_ARRAYS, extractIdentifiers } from '../../util/bibIdentifiers'; @@ -69,4 +70,25 @@ const formToBroker = (submittedRecord, { operation = CREATE } = {}) => { }; }; -export { brokerToForm, formToBroker }; +// A new transaction linked to the original only by prevReqId, so the original's +// protocol header and any Retry linkage go. +const formToRevision = (submittedRecord, prevReqId) => { + const record = formToBroker(submittedRecord); + const illRequest = omit(record.illRequest, 'header'); + const serviceInfo = omit(illRequest.serviceInfo, 'requestingAgencyPreviousRequestId'); + + return { + ...record, + prevReqId, + illRequest: { + ...illRequest, + serviceInfo: { + ...serviceInfo, + requestType: 'New', + serviceLevel: serviceInfo.serviceLevel ?? { '#text': 'Standard' }, + }, + }, + }; +}; + +export { brokerToForm, formToBroker, formToRevision }; diff --git a/ui-rs/src/components/PatronRequestForm/submissionError.js b/ui-rs/src/components/PatronRequestForm/submissionError.js new file mode 100644 index 0000000..fb82e0d --- /dev/null +++ b/ui-rs/src/components/PatronRequestForm/submissionError.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import { FORM_ERROR } from 'final-form'; +import { KeyValue } from '@folio/stripes/components'; + +// Return from onSubmit: Final Form then counts the submit as failed, which keeps +// the leave prompt armed. +const submissionError = (callout, labelId, err) => { + callout.sendCallout({ + type: 'error', + message: ( + } + value={err?.message || ''} + /> + ), + }); + return { [FORM_ERROR]: err?.message || labelId }; +}; + +export default submissionError; diff --git a/ui-rs/src/index.js b/ui-rs/src/index.js index 9a96c9f..135a85e 100644 --- a/ui-rs/src/index.js +++ b/ui-rs/src/index.js @@ -9,6 +9,7 @@ import EditRoute from './routes/EditRoute'; import PatronRequestsRoute from './routes/PatronRequestsRoute'; import PullSlipRoute from './routes/PullSlipRoute'; import PullSlipsRoute from './routes/PullSlipsRoute'; +import RerequestRoute from './routes/RerequestRoute'; import ViewRoute from './routes/ViewRoute'; const ResourceSharing = (props) => { @@ -64,6 +65,9 @@ const ResourceSharing = (props) => { {appName === 'request' && } + {appName === 'request' && + + } { }; try { await creator.mutateAsync(newRecord); + return undefined; } catch (err) { - callout.sendCallout({ - type: 'error', - message: ( - } - value={err?.message || ''} - /> - ), - }); + return submissionError(callout, 'ui-rs.create.error', err); } }; return ( - -
- {({ form, handleSubmit, pristine, submitting, submitSucceeded }) => ( - - - - } - renderEnd={ - - } - /> - } - paneTitle={} - > - - - - - {prompt => } - -
- )} - -
+
+ {({ form, pristine }) => ( + + + + )} +
); }; diff --git a/ui-rs/src/routes/EditRoute.js b/ui-rs/src/routes/EditRoute.js index 9716db5..15ecae7 100644 --- a/ui-rs/src/routes/EditRoute.js +++ b/ui-rs/src/routes/EditRoute.js @@ -1,12 +1,12 @@ import React, { useContext } from 'react'; -import { FormattedMessage } from 'react-intl'; import { Form } from 'react-final-form'; import { useMutation, useQueryClient } from 'react-query'; -import { Prompt, Redirect, useHistory, useLocation } from 'react-router-dom'; -import { Button, Pane, Paneset, PaneFooter, KeyValue } from '@folio/stripes/components'; +import { Redirect, useHistory, useLocation } from 'react-router-dom'; import { CalloutContext } from '@folio/stripes/core'; import { useCloseDirect, useOkapiKy, useOkapiQuery, upNLevels } from '@projectreshare/stripes-reshare'; import PatronRequestForm from '../components/PatronRequestForm'; +import PatronRequestFormPane from '../components/PatronRequestForm/PatronRequestFormPane'; +import submissionError from '../components/PatronRequestForm/submissionError'; import useOptions from '../components/PatronRequestForm/useOptions'; import { brokerToForm, formToBroker } from '../components/PatronRequestForm/formMapping'; import { EDIT } from '../components/PatronRequestForm/operations'; @@ -57,68 +57,25 @@ const EditRoute = ({ match }) => { const updatedRecord = formToBroker(submittedRecord, { operation: EDIT }); try { await editor.mutateAsync(updatedRecord); + return undefined; } catch (err) { - callout.sendCallout({ - type: 'error', - message: ( - } - value={err?.message || ''} - /> - ), - }); + return submissionError(callout, 'ui-rs.edit.error', err); } }; return ( - -
- {({ form, handleSubmit, pristine, submitting, submitSucceeded }) => ( - - - - } - renderEnd={ - - } - /> - } - paneTitle={} - > - - - - - {prompt => } - -
- )} - -
+
+ {({ form, pristine }) => ( + + + + )} +
); }; diff --git a/ui-rs/src/routes/FlowRoute.test.js b/ui-rs/src/routes/FlowRoute.test.js index 2dbeba7..bda6b0c 100644 --- a/ui-rs/src/routes/FlowRoute.test.js +++ b/ui-rs/src/routes/FlowRoute.test.js @@ -6,11 +6,13 @@ import { within, } from '@folio/jest-config-stripes/testing-library/react'; +import { createMemoryHistory } from 'history'; import { renderWithRs } from '@projectreshare/stripes-reshare/testing/renderWithRs'; import { useNotificationList } from '../components/chat/useNotifications'; import FlowRoute from './FlowRoute'; const mockPerformAction = jest.fn(() => Promise.resolve()); +const mockIsActionPending = jest.fn(() => false); jest.mock('../components/chat/useNotifications', () => ({ useNotificationList: jest.fn(), @@ -22,7 +24,7 @@ jest.mock('@folio/stripes-components/lib/TextArea', () => require('../test/textA jest.mock('@projectreshare/stripes-reshare', () => ({ ...jest.requireActual('@projectreshare/stripes-reshare'), usePerformAction: () => mockPerformAction, - useIsActionPending: () => false, + useIsActionPending: (...args) => mockIsActionPending(...args), })); // FlowRoute receives request/actions as props, so it never queries; only @@ -162,6 +164,7 @@ const rowContaining = (text) => { describe('FlowRoute', () => { beforeEach(() => { jest.clearAllMocks(); + mockIsActionPending.mockReturnValue(false); useNotificationList.mockReturnValue({ data: { items: conditionNotifications } }); }); @@ -342,6 +345,35 @@ describe('FlowRoute', () => { ); }); + describe('rerequest', () => { + const rerequestActions = [{ name: 'rerequest', parameters: ['noop'] }]; + const renderWithHistory = () => { + const history = createMemoryHistory({ initialEntries: [`/requests/${requestFixture.id}/flow?foo=bar`] }); + renderWithRs(, { history }); + return history; + }; + const rerequestButton = () => screen.getByText('stripes-reshare.actions.rerequest').closest('button'); + + it('opens the revision form instead of performing the action', () => { + const history = renderWithHistory(); + + fireEvent.click(rerequestButton()); + + expect(history.location).toMatchObject({ + pathname: `/requests/${requestFixture.id}/rerequest`, + search: '?foo=bar', + }); + expect(mockPerformAction).not.toHaveBeenCalled(); + }); + + it('is disabled while an action on the request is pending', () => { + mockIsActionPending.mockReturnValue(true); + renderWithHistory(); + + expect(rerequestButton()).toBeDisabled(); + }); + }); + describe('due dates', () => { const dueDateInput = label => screen.getByLabelText(label); const toggle = (name, scope = screen) => scope.getByRole('button', { name }); diff --git a/ui-rs/src/routes/RerequestRoute.js b/ui-rs/src/routes/RerequestRoute.js new file mode 100644 index 0000000..213a55e --- /dev/null +++ b/ui-rs/src/routes/RerequestRoute.js @@ -0,0 +1,130 @@ +import React, { useContext, useEffect, useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Form } from 'react-final-form'; +import { useQueryClient } from 'react-query'; +import { Redirect, useHistory, useLocation } from 'react-router-dom'; +import { MessageBanner } from '@folio/stripes/components'; +import { CalloutContext } from '@folio/stripes/core'; +import { DirectLink, useCloseDirect, useIsActionPending, useOkapiKy, useOkapiQuery, upNLevels } from '@projectreshare/stripes-reshare'; +import PatronRequestForm from '../components/PatronRequestForm'; +import PatronRequestFormPane from '../components/PatronRequestForm/PatronRequestFormPane'; +import submissionError from '../components/PatronRequestForm/submissionError'; +import useOptions from '../components/PatronRequestForm/useOptions'; +import { brokerToForm, formToRevision } from '../components/PatronRequestForm/formMapping'; +import handleSISelect from '../components/PatronRequestForm/handleSISelect'; + +const RerequestRoute = ({ match }) => { + const id = match.params?.id; + const history = useHistory(); + const routerLocation = useLocation(); + const callout = useContext(CalloutContext); + const queryClient = useQueryClient(); + const okapiKy = useOkapiKy(); + const requestView = upNLevels(routerLocation, 1); + const requestsPath = routerLocation.pathname.replace(/\/[^/]+\/rerequest$/, ''); + const close = useCloseDirect(requestView); + + // Fixed once set: reinitialising Final Form on a refetch would discard edits. + const initialValues = useRef(null); + // Only a failure before the form opens reaches the error boundary; later + // ones must not take the draft with them. + const queryErrorOptions = { useErrorBoundary: () => !initialValues.current }; + + const recordKey = `broker/patron_requests/${id}`; + const actionsKey = `${recordKey}/actions`; + // Open on fresh data, not cache: someone may already have revised it. + const { data: request, isFetchedAfterMount: hasRequestLoaded } = useOkapiQuery( + recordKey, + { notifyOnChangeProps: 'tracked', ...queryErrorOptions } + ); + const { data: actionsData, isFetchedAfterMount: haveActionsLoaded } = useOkapiQuery(actionsKey, queryErrorOptions); + const { options, isSuccess: optionsLoaded } = useOptions(); + const actionPending = useIsActionPending(id); + + // The POSTs outlive the form; don't navigate if staff have already left. + const unmounted = useRef(false); + useEffect(() => () => { unmounted.current = true; }, []); + + if (!hasRequestLoaded || !haveActionsLoaded || !optionsLoaded) return null; + + const canRerequest = (actionsData?.actions ?? []).some(a => a.name === 'rerequest'); + + if (!initialValues.current) { + if (!canRerequest) return ; + initialValues.current = brokerToForm(request); + } + + // Cancel first: a fetch already running would be reused, and its response + // from before the POSTs would clear the invalidation. + const refresh = keys => Promise.all(keys.map(key => { + queryClient.cancelQueries(key); + return queryClient.invalidateQueries(key); + })); + + const fail = async (labelId, err) => { + const result = submissionError(callout, labelId, err); + // A competing revision may be why; refresh to show its link. + await refresh([recordKey, actionsKey]); + return result; + }; + + const submit = async submittedRecord => { + // Rerequest first: the broker withdraws it once the new request is linked. + // noop stops it creating its own unedited copy. Not usePerformAction, which + // would invalidate between the POSTs. + try { + const res = await okapiKy.post(`${recordKey}/action`, { + json: { action: 'rerequest', actionParams: { noop: true } }, + }); + const result = await res.json(); + if (result.outcome !== 'success') throw new Error(result.message || result.result || ''); + } catch (err) { + return fail('ui-rs.rerequest.actionError', err); + } + + let created; + try { + const res = await okapiKy.post('broker/patron_requests', { + json: formToRevision(submittedRecord, id), + }); + created = await res.json(); + } catch (err) { + return fail('ui-rs.rerequest.createError', err); + } + + refresh([recordKey, actionsKey, `${recordKey}/events`, 'broker/patron_requests']); + if (unmounted.current) return undefined; + history.replace(`${requestsPath}/${created.id}${routerLocation.search}`); + return undefined; + }; + + return ( +
+ {({ form }) => ( + + {!canRerequest && + + + {request?.nextReqId && + <> + {' '} + + + + + } + + } + + + )} +
+ ); +}; + +// The router reuses the component across ids; a new id needs a fresh draft. +export default props => ; diff --git a/ui-rs/src/routes/RerequestRoute.test.js b/ui-rs/src/routes/RerequestRoute.test.js new file mode 100644 index 0000000..88fa766 --- /dev/null +++ b/ui-rs/src/routes/RerequestRoute.test.js @@ -0,0 +1,351 @@ +import React from 'react'; +import { Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { act, fireEvent, screen, waitFor } from '@folio/jest-config-stripes/testing-library/react'; + +import { renderWithRs } from '@projectreshare/stripes-reshare/testing/renderWithRs'; +import { makeOkapiKyMock } from '@projectreshare/stripes-reshare/testing/okapiKyMock'; +import { quietQueryLog } from '../test/quietQueryLog'; +import RerequestRoute from './RerequestRoute'; + +const mockOkapi = makeOkapiKyMock(); + +jest.mock('@folio/stripes-components/lib/Icon', () => require('@projectreshare/stripes-reshare/testing/iconMock').default); +jest.mock('@folio/stripes-components/lib/TextArea', () => require('../test/textAreaMock').default); + +jest.mock('@folio/stripes/core', () => require('../test/stripesCore').makeStripesCoreMock(() => mockOkapi)); + +const { CalloutContext } = require('@folio/stripes/core'); + +const sendCallout = jest.fn(); + +// A cancelled Retry successor: carries all the protocol identity a revision drops. +const cancelledRequest = (overrides = {}) => ({ + id: 'req-1', + state: 'CANCELLED', + side: 'borrowing', + stateModel: 'default', + prevReqId: 'req-0', + internalNote: 'Staff only note', + requesterPickupLocationId: 'branch-e', + illRequest: { + header: { + requestingAgencyRequestId: 'req-1', + supplyingAgencyRequestId: 'sup-9', + timestamp: '2026-09-01T00:00:00Z', + }, + patronInfo: { patronId: 'p1', givenName: 'Ada', surname: 'Lovelace' }, + serviceInfo: { + serviceType: 'Loan', + serviceLevel: { '#text': 'Express' }, + requestType: 'Retry', + requestingAgencyPreviousRequestId: 'req-0', + }, + bibliographicInfo: { + title: 'Original Title', + author: 'Some Author', + bibliographicItemId: [ + { bibliographicItemIdentifier: '9781234567890', bibliographicItemIdentifierCode: { '#text': 'ISBN' } }, + { bibliographicItemIdentifier: 'M-2306-7118-7', bibliographicItemIdentifierCode: { '#text': 'ISMN' } }, + ], + bibliographicRecordId: [ + { bibliographicRecordIdentifier: 'lccn-9', bibliographicRecordIdentifierCode: { '#text': 'LCCN' } }, + ], + supplierUniqueRecordId: 'sys-42', + }, + publicationInfo: { publisher: 'Pub Co' }, + }, + ...overrides, +}); + +const rerequestable = { actions: [{ name: 'rerequest', parameters: ['noop'] }] }; +const noActions = { actions: [] }; + +// Reassigned mid-test to model a refresh or another session's change. +let broker; + +const renderRerequest = ({ history } = {}) => { + mockOkapi.setResponses({ + 'broker/patron_requests/req-1': () => broker.request, + 'broker/patron_requests/req-1/actions': () => broker.actions, + 'directory/entries/owned': { + items: [ + { id: 'branch-e', name: 'East Branch', type: 'Branch' }, + { id: 'branch-w', name: 'West Branch', type: 'Branch' }, + ], + }, + }); + const memoryHistory = history ?? createMemoryHistory({ initialEntries: ['/requests/req-1/rerequest?foo=bar'] }); + const rendered = renderWithRs( + + + , + { history: memoryHistory } + ); + return { ...rendered, history: memoryHistory }; +}; + +const respond = (body) => ({ json: async () => body }); + +const deferred = () => { + let resolve; + let done = false; + const promise = new Promise(r => { resolve = r; }).finally(() => { done = true; }); + return { promise, resolve, settled: () => done }; +}; + +const brokerPosts = ({ action = { outcome: 'success' }, created = { id: 'req-2' } } = {}) => { + mockOkapi.post.mockImplementation(async (path) => respond(path.endsWith('/action') ? action : created)); +}; + +const postPaths = () => mockOkapi.post.mock.calls.map(([path]) => path); +const createdPayload = () => mockOkapi.post.mock.calls.find(([path]) => path === 'broker/patron_requests')[1].json; + +const fieldByName = (name) => Array.from(document.querySelectorAll('[name]')) + .find(el => el.getAttribute('name') === name); +const setField = (name, value) => fireEvent.change(fieldByName(name), { target: { value } }); +const submitButton = () => document.querySelector('button[type="submit"]'); +const cancelButton = () => document.querySelector('#clickable-cancel-request-form'); +const loaded = () => waitFor(() => expect(fieldByName('bibliographicInfo.title')?.value).toBe('Original Title')); + +const itemIdFor = (json, code) => json.illRequest.bibliographicInfo.bibliographicItemId + ?.find(i => i.bibliographicItemIdentifierCode?.['#text'] === code)?.bibliographicItemIdentifier; +const recordIdFor = (json, code) => json.illRequest.bibliographicInfo.bibliographicRecordId + ?.find(i => i.bibliographicRecordIdentifierCode?.['#text'] === code)?.bibliographicRecordIdentifier; + +describe('RerequestRoute', () => { + quietQueryLog(/^(Boom|Duplicate|Offline)$/); // failure tests reject requests on purpose + + beforeEach(() => { + jest.clearAllMocks(); + broker = { request: cancelledRequest(), actions: rerequestable }; + brokerPosts(); + }); + + it('prefills from the original and submits it unchanged as a new, linked request', async () => { + const { history, queryClient } = renderRerequest(); + const invalidate = jest.spyOn(queryClient, 'invalidateQueries'); + await loaded(); + + expect(screen.getAllByText('ui-rs.rerequestPatronRequest').length).toBeGreaterThan(0); + expect(fieldByName('patronInfo.patronId').value).toBe('p1'); + expect(fieldByName('identifiers.ISBN').value).toBe('9781234567890'); + expect(fieldByName('systemInstanceIdentifier').value).toBe('sys-42'); + expect(fieldByName('requesterPickupLocationId').value).toBe('branch-e'); + expect(fieldByName('internalNote').value).toBe('Staff only note'); + expect(fieldByName('publicationInfo.publisher').value).toBe('Pub Co'); + expect(submitButton()).toBeEnabled(); + + fireEvent.click(submitButton()); + + await waitFor(() => expect(history.location.pathname).toBe('/requests/req-2')); + expect(history.location.search).toBe('?foo=bar'); + + expect(postPaths()).toEqual(['broker/patron_requests/req-1/action', 'broker/patron_requests']); + expect(mockOkapi.post.mock.calls[0][1].json).toEqual({ action: 'rerequest', actionParams: { noop: true } }); + + const json = createdPayload(); + expect(json.prevReqId).toBe('req-1'); + expect(json.patron).toBe('p1'); + expect(json.internalNote).toBe('Staff only note'); + expect(json.requesterPickupLocationId).toBe('branch-e'); + expect(json.illRequest).not.toHaveProperty('header'); + expect(json.illRequest.serviceInfo).toEqual({ + serviceType: 'Loan', + serviceLevel: { '#text': 'Express' }, + requestType: 'New', + }); + expect(json.illRequest.bibliographicInfo.title).toBe('Original Title'); + expect(json.illRequest.bibliographicInfo.supplierUniqueRecordId).toBe('sys-42'); + expect(itemIdFor(json, 'ISBN')).toBe('9781234567890'); + expect(itemIdFor(json, 'ISMN')).toBe('M-2306-7118-7'); + expect(recordIdFor(json, 'LCCN')).toBe('lccn-9'); + expect(json.illRequest.publicationInfo).toEqual({ publisher: 'Pub Co' }); + ['id', 'nextReqId', 'state', 'side', 'stateModel', 'supplierSymbol', 'items'].forEach(key => { + expect(json).not.toHaveProperty(key); + }); + + expect(invalidate.mock.calls.map(([key]) => key)).toEqual(expect.arrayContaining([ + 'broker/patron_requests/req-1', + 'broker/patron_requests/req-1/actions', + 'broker/patron_requests/req-1/events', + 'broker/patron_requests', + ])); + expect(sendCallout).not.toHaveBeenCalled(); + }); + + it('sends edits, omits a cleared internal note and defaults a missing service level', async () => { + broker.request = cancelledRequest(); + delete broker.request.illRequest.serviceInfo.serviceLevel; + const { history } = renderRerequest(); + await loaded(); + + setField('bibliographicInfo.title', 'Corrected Title'); + setField('identifiers.ISBN', ''); + setField('internalNote', ''); + fireEvent.click(submitButton()); + + await waitFor(() => expect(history.location.pathname).toBe('/requests/req-2')); + const json = createdPayload(); + expect(json.illRequest.bibliographicInfo.title).toBe('Corrected Title'); + expect(itemIdFor(json, 'ISBN')).toBeUndefined(); + expect(itemIdFor(json, 'ISMN')).toBe('M-2306-7118-7'); + expect(json).not.toHaveProperty('internalNote'); + expect(json.illRequest.serviceInfo.serviceLevel).toEqual({ '#text': 'Standard' }); + }); + + it('returns to the original without any mutation when rerequest is not offered', async () => { + broker.actions = noActions; + const { history } = renderRerequest(); + + await waitFor(() => expect(history.location.pathname).toBe('/requests/req-1')); + expect(history.location.search).toBe('?foo=bar'); + expect(submitButton()).toBeNull(); + expect(mockOkapi.post).not.toHaveBeenCalled(); + }); + + it('cancels back to the original request without any mutation', async () => { + const { history } = renderRerequest(); + await loaded(); + + fireEvent.click(document.querySelector('#clickable-cancel-request-form')); + + await waitFor(() => expect(history.location.pathname).toBe('/requests/req-1')); + expect(mockOkapi.post).not.toHaveBeenCalled(); + }); + + it('creates nothing when the action does not succeed, and keeps the draft guarded', async () => { + brokerPosts({ action: { outcome: 'failure', message: 'Not allowed' } }); + const confirm = jest.fn((message, callback) => callback(false)); + const history = createMemoryHistory({ + initialEntries: ['/requests/req-1/rerequest?foo=bar'], + getUserConfirmation: confirm, + }); + renderRerequest({ history }); + await loaded(); + + setField('bibliographicInfo.title', 'Corrected Title'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(sendCallout).toHaveBeenCalledTimes(1)); + expect(postPaths()).toEqual(['broker/patron_requests/req-1/action']); + expect(fieldByName('bibliographicInfo.title').value).toBe('Corrected Title'); + + fireEvent.click(document.querySelector('#clickable-cancel-request-form')); + expect(confirm).toHaveBeenCalledTimes(1); + expect(history.location.pathname).toBe('/requests/req-1/rerequest'); + }); + + it('reports a failed creation and allows a retry', async () => { + mockOkapi.post + .mockImplementationOnce(async () => respond({ outcome: 'success' })) + .mockImplementationOnce(async () => { throw new Error('Boom'); }); + const { history } = renderRerequest(); + await loaded(); + + setField('bibliographicInfo.title', 'Corrected Title'); + fireEvent.click(submitButton()); + + await waitFor(() => expect(sendCallout).toHaveBeenCalledTimes(1)); + const { message } = sendCallout.mock.calls[0][0]; + expect(message.props.label.props.id).toBe('ui-rs.rerequest.createError'); + expect(message.props.value).toBe('Boom'); + expect(fieldByName('bibliographicInfo.title').value).toBe('Corrected Title'); + await waitFor(() => expect(submitButton()).toBeEnabled()); + + fireEvent.click(submitButton()); + + await waitFor(() => expect(history.location.pathname).toBe('/requests/req-2')); + expect(postPaths()).toEqual([ + 'broker/patron_requests/req-1/action', + 'broker/patron_requests', + 'broker/patron_requests/req-1/action', + 'broker/patron_requests', + ]); + }); + + it('links to a successor created elsewhere and blocks resubmission', async () => { + mockOkapi.post + .mockImplementationOnce(async () => respond({ outcome: 'success' })) + .mockImplementationOnce(async () => { + broker = { request: cancelledRequest({ nextReqId: 'req-3' }), actions: noActions }; + throw new Error('Duplicate'); + }); + renderRerequest(); + await loaded(); + setField('bibliographicInfo.title', 'Corrected Title'); + + fireEvent.click(submitButton()); + + const link = (await screen.findByText('ui-rs.flow.info.succeededByLink')).closest('a'); + expect(link).toHaveAttribute('href', '/requests/req-3?foo=bar'); + expect(screen.getByText('ui-rs.rerequest.unavailable')).toBeInTheDocument(); + expect(submitButton()).toBeDisabled(); + expect(sendCallout).toHaveBeenCalledTimes(1); + expect(fieldByName('bibliographicInfo.title').value).toBe('Corrected Title'); + }); + + it('locks the form while submitting and refreshes only once the request is created', async () => { + const noop = deferred(); + let invalidatedBeforeCreate; + const { history, queryClient } = renderRerequest(); + const invalidate = jest.spyOn(queryClient, 'invalidateQueries'); + mockOkapi.post + .mockImplementationOnce(() => noop.promise) + .mockImplementationOnce(async () => { + invalidatedBeforeCreate = invalidate.mock.calls.length; + return respond({ id: 'req-2' }); + }); + await loaded(); + + fireEvent.click(submitButton()); + await waitFor(() => expect(submitButton()).toBeDisabled()); + expect(cancelButton()).toBeDisabled(); + fireEvent.click(submitButton()); + fireEvent.submit(document.querySelector('form')); + expect(mockOkapi.post).toHaveBeenCalledTimes(1); + + noop.resolve(respond({ outcome: 'success' })); + await waitFor(() => expect(history.location.pathname).toBe('/requests/req-2')); + expect(postPaths()).toEqual(['broker/patron_requests/req-1/action', 'broker/patron_requests']); + expect(invalidatedBeforeCreate).toBe(0); + }); + + it('does not redirect a page staff moved to while the creation was in flight', async () => { + const create = deferred(); + mockOkapi.post + .mockImplementationOnce(async () => respond({ outcome: 'success' })) + .mockImplementationOnce(() => create.promise); + const { history } = renderRerequest(); + await loaded(); + + fireEvent.click(submitButton()); + await waitFor(() => expect(mockOkapi.post).toHaveBeenCalledTimes(2)); + act(() => history.push('/elsewhere')); + + create.resolve(respond({ id: 'req-2' })); + await waitFor(() => expect(create.settled()).toBe(true)); + expect(history.location.pathname).toBe('/elsewhere'); + }); + + it('keeps the draft when refreshing the original fails', async () => { + mockOkapi.post + .mockImplementationOnce(async () => respond({ outcome: 'success' })) + .mockImplementationOnce(async () => { + broker = { + get request() { throw new Error('Offline'); }, + get actions() { throw new Error('Offline'); }, + }; + throw new Error('Boom'); + }); + renderRerequest(); + await loaded(); + setField('bibliographicInfo.title', 'Corrected Title'); + + fireEvent.click(submitButton()); + + await waitFor(() => expect(sendCallout).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(submitButton()).toBeEnabled()); + expect(fieldByName('bibliographicInfo.title').value).toBe('Corrected Title'); + }); +}); diff --git a/ui-rs/translations/ui-rs/en.json b/ui-rs/translations/ui-rs/en.json index 9a7fd16..fab4e3f 100644 --- a/ui-rs/translations/ui-rs/en.json +++ b/ui-rs/translations/ui-rs/en.json @@ -72,6 +72,9 @@ "create.error": "Error creating patron request", "edit.error": "Error editing patron request", "rerequestPatronRequest": "Create revised request", + "rerequest.actionError": "Error updating the original request. The revised request was not created.", + "rerequest.createError": "Error creating revised request", + "rerequest.unavailable": "This request can no longer be revised.", "revalidatePatronRequest": "Resubmit request", "closeNewPatronRequest": "Close new patron request", "information.hrid": "Request ID",