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={
+
+ }
+ />
+ }
+ >
+
+
+ {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 (
-
-
-
+
);
};
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, 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",