diff --git a/ui-rs/src/components/PatronRequestForm/PatronRequestForm.js b/ui-rs/src/components/PatronRequestForm/PatronRequestForm.js
index 364ddaa..8c8fb55 100644
--- a/ui-rs/src/components/PatronRequestForm/PatronRequestForm.js
+++ b/ui-rs/src/components/PatronRequestForm/PatronRequestForm.js
@@ -1,6 +1,6 @@
-import React, { useEffect } from 'react';
+import React from 'react';
import { FormattedMessage } from 'react-intl';
-import { Field, useForm, useFormState } from 'react-final-form';
+import { Field, useFormState } from 'react-final-form';
import {
AccordionSet,
Accordion,
@@ -16,75 +16,19 @@ import {
import { required } from '@folio/stripes/util';
import { Pluggable, useStripes } from '@folio/stripes/core';
-const PatronRequestForm = ({ autopopulate, copyrightTypes, enabledFields,
- serviceLevels, publicationTypes, locations, requesters, tiersByRequester, onSISelect, operation, patronRequest }) => {
- const { change } = useForm();
+const PatronRequestForm = ({ autopopulate, selectOptions, onSISelect }) => {
+ const { copyrightTypes, publicationTypes, locations } = selectOptions;
const { values } = useFormState();
const isCopyReq = values?.serviceInfo?.serviceType === 'Copy';
const stripes = useStripes();
- const EDIT = 'update';
-
- const currentRequester = values.requesterSymbol?.value ?? requesters[0];
- const tiers = tiersByRequester?.[currentRequester]?.filter(tier => tier.type === values.serviceType?.value) ?? [];
- const showCost = stripes.config?.reshare?.showCost;
- const useTiers = stripes.config?.reshare?.useTiers;
- const resetTier = () => { if (useTiers) change('tier', undefined); };
- const tier = useTiers && values.tier ? tiers.find(t => t.id === values.tier) : undefined;
- useEffect(() => {
- // When using tiers we want the cost/level from the selected tier except when we're editing as we may then be
- // displaying a higher cost from an accepted condition and won't be able to select a different tier as that's
- // not editable once a request is submitted.
- if (tier && operation !== EDIT) {
- if (showCost) change('maximumCostsMonetaryValue', tier?.cost);
- change('serviceLevel.value', tier?.level?.toLowerCase());
- }
- }, [change, operation, showCost, tier]);
// TODO: Broker API
- // const freePickupLocation = useSetting('free_text_pickup_location');
// const ncipBorrowerCheck = useSetting('borrower_check', 'hostLMSIntegration');
- // const routingAdapterSetting = useSetting('routing_adapter');
- const freePickupLocation = { value: 'no', isSuccess: true };
const ncipBorrowerCheck = { value: 'none', isSuccess: true };
- const routingAdapterSetting = { value: 'disabled', isSuccess: true };
-
- useEffect(() => {
- if (locations?.length === 1) {
- change('pickupLocationSlug', locations[0]?.value);
- }
- }, [locations, change]);
-
- if ([freePickupLocation, ncipBorrowerCheck, routingAdapterSetting].some(v => v.isSuccess !== true)) return null;
-
- function applyDisabledToFields(children) {
- return React.Children.map(children, child => {
- if (!React.isValidElement(child)) return child;
-
- if (child.type === Field) {
- const { name, disabled } = child.props;
-
- // Only apply if "disabled" is not already set
- if (disabled === undefined && name !== undefined) {
- return React.cloneElement(child, {
- disabled: !enabledFields.includes(name),
- // Can't fulfil the validation if you can't change the field from its current value
- validate: undefined,
- });
- }
- return child;
- }
-
- // If it has children, recursively process them
- if (child.props && child.props.children) {
- const newChildren = applyDisabledToFields(child.props.children);
- return React.cloneElement(child, {}, newChildren);
- }
- return child;
- });
- }
+ if (ncipBorrowerCheck.isSuccess !== true) return null;
- const requestForm = (
+ return (
@@ -104,32 +48,20 @@ const PatronRequestForm = ({ autopopulate, copyrightTypes, enabledFields,
component={Datepicker}
/>
- { freePickupLocation.value !== 'yes' &&
}
placeholder=" "
component={Select}
dataOptions={locations}
/>
- }
- { freePickupLocation.value === 'yes' &&
-
- }
- component={TextField}
- />
-
- }
}
name="serviceInfo.serviceType"
type="radio"
@@ -138,7 +70,6 @@ const PatronRequestForm = ({ autopopulate, copyrightTypes, enabledFields,
}
name="serviceInfo.serviceType"
type="radio"
@@ -179,36 +110,6 @@ const PatronRequestForm = ({ autopopulate, copyrightTypes, enabledFields,
*/}
)}
- { (requesters.length > 1 && operation !== EDIT) && (
-
-
- }
- placeholder=" "
- component={Select}
- dataOptions={requesters}
- required
- validate={required}
- />
-
-
- )}
- { ((patronRequest?.stateModel?.shortcode === 'SLNPRequester' || patronRequest?.stateModel?.shortcode === 'SLNPNonReturnableRequester')
- && operation === EDIT) && (
-
-
- }
- component={TextField}
- disabled
- />
-
-
- )
-
- }
-
- {/* TODO: Broker API */}
- {/* {useTiers &&
-
-
- }
- component={Select}
- dataOptions={tiers}
- required
- validate={required}
- />
-
-
- } */}
-
-
- }
- placeholder=" "
- component={Select}
- dataOptions={serviceLevels}
- disabled={useTiers}
- validate={required}
- />
-
- {showCost &&
-
- }
- component={TextField}
- disabled={useTiers}
- />
-
- }
-
-
+ {/* TODO: tiers pending directory endpoint to fetch entry corresponding to tenant */}
+ {/*
+ }
+ component={Select}
+ dataOptions={tiers}
+ required
+ validate={required}
+ />
+ */}
{isCopyReq &&
-
-
}
displayWhenOpen={}
selectInstance={onSISelect}
-
/>}
>
@@ -303,8 +172,8 @@ const PatronRequestForm = ({ autopopulate, copyrightTypes, enabledFields,
label={}
component={TextField}
endControl={
- // The padding on endControl the necessitates this margin when using Button rather than IconButton
- // will be removed soon, perhaps Quesnelia
+ // TextField endControl still has right padding as of Sunflower; offset it
+ // when using Button rather than IconButton so the button sits flush.
);
-
- if (Array.isArray(enabledFields)) {
- return applyDisabledToFields(requestForm);
- } else {
- return requestForm;
- }
};
export default PatronRequestForm;
diff --git a/ui-rs/src/components/PatronRequestForm/formMapping.js b/ui-rs/src/components/PatronRequestForm/formMapping.js
new file mode 100644
index 0000000..7815934
--- /dev/null
+++ b/ui-rs/src/components/PatronRequestForm/formMapping.js
@@ -0,0 +1,84 @@
+import { CREATE, EDIT } from './operations';
+
+const ID_ARRAYS = {
+ bibliographicItemId: {
+ idKey: 'bibliographicItemIdentifier',
+ codeKey: 'bibliographicItemIdentifierCode',
+ codes: ['ISBN', 'ISSN'],
+ },
+ bibliographicRecordId: {
+ idKey: 'bibliographicRecordIdentifier',
+ codeKey: 'bibliographicRecordIdentifierCode',
+ codes: ['OCLC'],
+ },
+};
+
+// Entries for the codes the form exposes, with any other code left as it was, so
+// identifiers we do not display survive a PUT.
+const rebuildIds = (entries, { idKey, codeKey, codes }, identifiers) => [
+ ...(entries ?? []).filter(e => !codes.includes(e?.[codeKey]?.['#text'])),
+ ...codes.filter(code => identifiers[code]).map(code => ({
+ [idKey]: identifiers[code],
+ [codeKey]: { '#text': code },
+ })),
+];
+
+// Lift the displayed codes into the form's `identifiers` fields, keeping the raw
+// arrays in bibliographicInfo for rebuildIds to merge back into.
+const brokerToForm = (request) => {
+ const illRequest = request?.illRequest ?? {};
+ const { supplierUniqueRecordId, ...bibliographicInfo } = illRequest.bibliographicInfo ?? {};
+
+ const identifiers = {};
+ Object.entries(ID_ARRAYS).forEach(([arrayKey, { idKey, codeKey, codes }]) => {
+ codes.forEach(code => {
+ const value = (bibliographicInfo[arrayKey] ?? [])
+ .find(e => e?.[codeKey]?.['#text'] === code)?.[idKey];
+ if (value) identifiers[code] = value;
+ });
+ });
+
+ return {
+ ...illRequest,
+ bibliographicInfo,
+ identifiers,
+ ...(supplierUniqueRecordId && { systemInstanceIdentifier: supplierUniqueRecordId }),
+ ...(request?.internalNote && { internalNote: request.internalNote }),
+ };
+};
+
+const formToBroker = (submittedRecord, { operation = CREATE } = {}) => {
+ const {
+ internalNote,
+ identifiers = {},
+ systemInstanceIdentifier,
+ ...illRequestFields
+ } = submittedRecord;
+
+ const bibliographicInfo = {
+ ...illRequestFields.bibliographicInfo,
+ supplierUniqueRecordId: systemInstanceIdentifier,
+ };
+ Object.entries(ID_ARRAYS).forEach(([arrayKey, spec]) => {
+ const entries = rebuildIds(illRequestFields.bibliographicInfo?.[arrayKey], spec, identifiers);
+ // Omit the key entirely rather than sending an empty array.
+ if (entries.length > 0) bibliographicInfo[arrayKey] = entries;
+ else delete bibliographicInfo[arrayKey];
+ });
+
+ // PUT needs an explicit empty string to clear a note; POST can omit an empty note.
+ const brokerInternalNote = operation === EDIT
+ ? { internalNote: internalNote ?? '' }
+ : (internalNote ? { internalNote } : {});
+
+ return {
+ patron: illRequestFields?.patronInfo?.patronId,
+ ...brokerInternalNote,
+ illRequest: {
+ ...illRequestFields,
+ bibliographicInfo,
+ },
+ };
+};
+
+export { brokerToForm, formToBroker };
diff --git a/ui-rs/src/components/PatronRequestForm/handleSISelect.js b/ui-rs/src/components/PatronRequestForm/handleSISelect.js
new file mode 100644
index 0000000..a3a071c
--- /dev/null
+++ b/ui-rs/src/components/PatronRequestForm/handleSISelect.js
@@ -0,0 +1,55 @@
+// Map shared-index fields into PatronRequestForm's nested field names.
+const SI_FIELD_MAP = {
+ title: 'bibliographicInfo.title',
+ author: 'bibliographicInfo.author',
+ edition: 'bibliographicInfo.edition',
+ isbn: 'identifiers.ISBN',
+ issn: 'identifiers.ISSN',
+ oclcNumber: 'identifiers.OCLC',
+ publisher: 'publicationInfo.publisher',
+ publicationDate: 'publicationInfo.publicationDate',
+ placeOfPublication: 'publicationInfo.placeOfPublication',
+ publicationType: "publicationInfo.publicationType['#text']",
+};
+
+const handleSISelect = (args, state, tools) => {
+ const leader = args?.[0]?.__leader__;
+ if (leader && leader?.[6] === 'a') {
+ const stval = state.formState.values?.serviceInfo?.serviceType;
+ const leaderField = leader?.[7];
+ const pubTypeMatch = {
+ 'Loan' : {
+ 'a' : 'chapter',
+ 'b' : 'article',
+ 'm' : 'book',
+ 's' : 'journal'
+ },
+ 'Copy' : {
+ 'a' : 'chapter',
+ 'b' : 'article',
+ 'm' : 'chapter',
+ 's' : 'article'
+ }
+ };
+
+ if (stval in pubTypeMatch) {
+ const pubTypeVal = pubTypeMatch[stval][leaderField];
+ if (pubTypeVal) {
+ args[0].publicationType = pubTypeVal;
+ }
+ }
+ }
+
+ Object.entries(args[0]).forEach(([field, value]) => {
+ const mappedField = SI_FIELD_MAP[field] ?? field;
+ tools.changeValue(state, mappedField, () => value);
+ });
+
+ // Clear the mapped fields the selected instance did not supply, so a second
+ // selection cannot leave values behind from the first.
+ Object.entries(SI_FIELD_MAP)
+ .filter(([field]) => !(field in args[0]))
+ .forEach(([, mappedField]) => tools.changeValue(state, mappedField, () => undefined));
+};
+
+export default handleSISelect;
diff --git a/ui-rs/src/components/PatronRequestForm/operations.js b/ui-rs/src/components/PatronRequestForm/operations.js
new file mode 100644
index 0000000..35d9a39
--- /dev/null
+++ b/ui-rs/src/components/PatronRequestForm/operations.js
@@ -0,0 +1,4 @@
+// Which form the user is working in; formToBroker takes one as its `operation`
+// option and treats CREATE as the default.
+export const CREATE = 'create';
+export const EDIT = 'edit';
diff --git a/ui-rs/src/components/PatronRequestForm/useOptions.js b/ui-rs/src/components/PatronRequestForm/useOptions.js
new file mode 100644
index 0000000..ea5422b
--- /dev/null
+++ b/ui-rs/src/components/PatronRequestForm/useOptions.js
@@ -0,0 +1,20 @@
+import { CopyrightCompliance, PublicationType } from '../../constants/iso18626';
+
+// Select dataOptions for PatronRequestForm. Static today, but the terms vary by
+// consortium and will come from the backend, so isSuccess is already reported
+// for routes to gate their first render on.
+const copyrightTypes = CopyrightCompliance.map(code => ({ label: code, value: code }));
+
+// Lowercased to match the publication types handleSISelect writes.
+const publicationTypes = PublicationType.map(code => ({ label: code, value: code.toLowerCase() }));
+
+// TODO: tiers and pickup locations pending the directory endpoints.
+const tiers = [];
+const locations = [];
+
+const useOptions = () => ({
+ options: { copyrightTypes, publicationTypes, tiers, locations },
+ isSuccess: true,
+});
+
+export default useOptions;
diff --git a/ui-rs/src/components/ViewMessageBanners.js b/ui-rs/src/components/ViewMessageBanners.js
index b26f2da..5133524 100644
--- a/ui-rs/src/components/ViewMessageBanners.js
+++ b/ui-rs/src/components/ViewMessageBanners.js
@@ -9,7 +9,7 @@ const ViewMessageBanners = ({ request }) => {
const { data } = useNotificationList(request?.id);
const lastCostStates = ['RES_COPY_AWAIT_PICKING', 'RES_AWAIT_SHIP'];
- const lastChanceForCost = stripes.config?.reshare?.useTiers && stripes.config?.reshare?.showCost && lastCostStates.includes(request?.state?.code);
+ const lastChanceForCost = stripes.config?.reshare?.showCost && lastCostStates.includes(request?.state?.code);
const relevantConditions = (data?.items || [])
.filter(n => n.kind === 'condition' && n.fromSymbol === request?.supplierSymbol);
diff --git a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js b/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js
index 0b41775..a44be52 100644
--- a/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js
+++ b/ui-rs/src/components/ViewPatronRequest/sections/EventHistory/EventHistory.js
@@ -23,7 +23,6 @@ const EventHistory = ({ record }) => {
`broker/patron_requests/${record.id}/events`,
{
enabled: !!record?.id,
- parseResponse: false,
staleTime: 2 * 60 * 1000,
notifyOnChangeProps: 'tracked',
}
diff --git a/ui-rs/src/index.js b/ui-rs/src/index.js
index 6029e29..1139fef 100644
--- a/ui-rs/src/index.js
+++ b/ui-rs/src/index.js
@@ -4,6 +4,7 @@ import Settings from './settings';
import AppNameContext from './AppNameContext';
import CreateRoute from './routes/CreateRoute';
+import EditRoute from './routes/EditRoute';
import PatronRequestsRoute from './routes/PatronRequestsRoute';
import PullSlipRoute from './routes/PullSlipRoute';
import PullSlipsRoute from './routes/PullSlipsRoute';
@@ -52,6 +53,9 @@ const ResourceSharing = (props) => {
{appName === 'request' &&
}
+ {appName === 'request' &&
+
+ }
{
- const leader = args?.[0]?.__leader__;
- if (leader && leader?.[6] === 'a') {
- const stval = state.formState.values?.serviceInfo?.serviceType;
- const leaderField = leader?.[7];
- const pubTypeMatch = {
- 'Loan' : {
- 'a' : 'chapter',
- 'b' : 'article',
- 'm' : 'book',
- 's' : 'journal'
- },
- 'Copy' : {
- 'a' : 'chapter',
- 'b' : 'article',
- 'm' : 'chapter',
- 's' : 'article'
- }
- };
-
- if (stval in pubTypeMatch) {
- const pubTypeVal = pubTypeMatch[stval][leaderField];
- if (pubTypeVal) {
- args[0].publicationType = pubTypeVal;
- }
- }
- }
-
- Object.entries(args[0]).forEach(([field, value]) => {
- const mappedField = SI_FIELD_MAP[field] ?? field;
- tools.changeValue(state, mappedField, () => value);
- });
-
- SI_FIELDS.filter(field => !(field in args[0])).forEach(field => {
- const mappedField = SI_FIELD_MAP[field] ?? field;
- tools.changeValue(state, mappedField, () => undefined);
- });
-};
+import useOptions from '../components/PatronRequestForm/useOptions';
+import { formToBroker } from '../components/PatronRequestForm/formMapping';
+import handleSISelect from '../components/PatronRequestForm/handleSISelect';
const CreateRoute = () => {
const history = useHistory();
@@ -72,53 +18,7 @@ const CreateRoute = () => {
const queryClient = useQueryClient();
const okapiKy = useOkapiKy();
const close = useCloseDirect();
- // We could provision these vocabs on the form directly but are passing them
- // in because they will end up requiring something from the backend as the
- // terms can vary by consortium.
- const copyrightTypes = CopyrightCompliance.map(value => ({ label: value, value }));
- const copyrightTypesLoaded = true;
- const serviceLevels = ServiceLevel.map(value => ({ label: value, value }));
- const serviceLevelsLoaded = true;
- const stripes = useStripes();
-
- // TODO: Broker API
- // const defaultRequesterSymbolSetting = useSetting('default_request_symbol', 'requests');
- // const routingAdapterSetting = useSetting('routing_adapter');
- const defaultRequesterSymbolSetting = { value: { label: 'Default', value: 'ISIL:DEFAULT' }, isSuccess: true };
- const routingAdapterSetting = { value: 'disabled', isSuccess: true };
-
- // const locationQuery = useOkapiQuery(
- // 'directory/entry',
- // {
- // searchParams: encodeURI('?filters=tags.value=i=pickup&filters=status.value==managed&perPage=1000'),
- // kyOpt: { throwHttpErrors: false },
- // useErrorBoundary: false,
- // refetchOnWindowFocus: false,
- // retryOnMount:false,
- // enabled: routingAdapterSetting.isSuccess === true && routingAdapterSetting.value !== 'disabled'
- // }
- // );
- const locationQuery = { isSuccess: true, data: [] };
-
- // const institutionQuery = useOkapiQuery(
- // 'directory/entry',
- // {
- // searchParams: encodeURI('?filters=type.value==institution&filters=status.value==managed&perPage=1000'),
- // kyOpt: { throwHttpErrors: false },
- // useErrorBoundary: false,
- // refetchOnWindowFocus: false,
- // retryOnMount:false,
- // enabled: routingAdapterSetting.isSuccess === true && routingAdapterSetting.value !== 'disabled'
- // }
- // );
- const institutionQuery = { isSuccess: true, data: [] };
-
- const publicationTypesList = ['ArchiveMaterial', 'Article', 'AudioBook',
- 'Book', 'Chapter', 'ConferenceProc', 'Game', 'GovernmentPubl', 'Image',
- 'Journal', 'Manuscript', 'Map', 'Movie', 'MusicRecording', 'MusicScore',
- 'Newspaper', 'Patent', 'Report', 'SoundRecording', 'Thesis'
- ];
- const publicationTypes = publicationTypesList.map(x => ({ label: x, value: x.toLowerCase() }));
+ const { options, isSuccess: optionsLoaded } = useOptions();
const creator = useMutation({
mutationFn: (newRecord) => okapiKy
@@ -140,47 +40,17 @@ const CreateRoute = () => {
},
});
- const validRequesterRecords = institutionQuery.isSuccess ? (institutionQuery.data
- .filter(rec => rec?.type?.value === 'institution' && rec?.symbols?.[0]?.authority?.symbol)) : [];
- const requesters = validRequesterRecords?.reduce((acc, cur) => ([...acc, { value: `${cur.symbols[0].authority.symbol}:${cur.symbols[0].symbol}`, label: cur.name }]), []);
- const requesterList = requesters?.length > 0 ? requesters : [defaultRequesterSymbolSetting.value];
- if (!(requesterList?.length)) {
- throw new Error('Cannot resolve symbol to create requests as');
- }
-
- // const directoryEntriesQuery = useNewDirectoryEntries();
- const directoryEntriesQuery = { isSuccess: true, data: { items: [] } };
-
- // Only proceed to render once everything is loaded
- if (!routingAdapterSetting.isSuccess) return null;
- const dirQueries = (routingAdapterSetting.value === 'disabled') ? [directoryEntriesQuery] : [locationQuery, institutionQuery];
- if (!routingAdapterSetting.isSuccess ||
- dirQueries.some(q => q.isSuccess !== true) ||
- !serviceLevelsLoaded ||
- !copyrightTypesLoaded) {
- return null;
- }
-
-
- // locations are where rec.type.value is 'branch' and there is a tag in rec.type.tags where the value is 'pickup'
- // and are formatted for the Select component as { value: lmsLocationCode, label: name }
- const pickupLocations = locationQuery.isSuccess ? (locationQuery.data
- .filter(rec => rec?.type?.value === 'branch'
- && rec?.tags.reduce((acc, cur) => acc || cur?.value === 'pickup', false))
- .reduce((acc, cur) => ([...acc, { value: cur.slug, label: cur.name }]), [])) : [];
-
-
- const apiLocations = directoryEntriesQuery.isSuccess
- ? directoryEntriesQuery.data?.items?.filter(item => item.type === 'branch')?.map(item => ({ label: item.name, value: item.name }))
- : [];
-
- const tiersByRequester = tiersBySymbol(directoryEntriesQuery.data?.items);
+ // Render nothing until the form's select data is loaded, so the whole form
+ // paints at once. Below every hook call, as it returns early.
+ if (!optionsLoaded) return null;
const initialValues = {
// TODO: Broker API
// copyrightType: defaultCopyrightSetting,
- // serviceLevel: { value: config?.useTiers ? undefined : defaultServiceLevelSetting.value },
serviceInfo: { serviceType: 'Loan' },
+ ...(options.locations?.length === 1 && {
+ pickupLocation: options.locations[0].value,
+ }),
};
const reg = /.+\/create\/(\d+)/;
@@ -191,98 +61,14 @@ const CreateRoute = () => {
initialValues.systemInstanceIdentifier = sysIdMatch[1];
}
- const getEntriesByType = (entryData, typeValue) => {
- return entryData?.items?.filter(entry => { return entry.type === typeValue; });
- };
-
- const getEntryByName = (entryList, nameValue) => {
- return entryList?.find(entry => { return entry.name === nameValue; });
- };
-
- const getShippingAddressEntry = entry => entry?.addresses?.find(address => address?.type === 'Shipping');
-
-
- const formatAddressEntryObject = (addressComponents, line1 = null) => {
- const addressStruct = {};
- for (let i = 0; i < addressComponents.length; i++) {
- const addressComponent = addressComponents[i];
- addressStruct[addressComponent.type] = addressComponent.value;
- }
- const resultObject = {};
-
- if (line1 || addressStruct.Other) {
- resultObject.line1 = line1 ?? addressStruct.Other;
- resultObject.line2 = addressStruct.Thoroughfare;
- } else {
- resultObject.line1 = addressStruct.Thoroughfare;
- }
- if (addressStruct.Locality) { resultObject.locality = addressStruct.Locality; }
- if (addressStruct.PostalCode) { resultObject.postalCode = addressStruct.PostalCode; }
- if (addressStruct.AdministrativeArea) { resultObject.region = addressStruct.AdministrativeArea; }
- if (addressStruct.CountryCode) { resultObject.country = addressStruct.CountryCode; }
-
- return resultObject;
- };
-
- const getAddressForPickupLocation = (entryData, pickupLocation) => {
- const branchEntries = getEntriesByType(entryData, 'branch');
- const branchEntry = getEntryByName(branchEntries, pickupLocation);
- let shippingAddressEntry = getShippingAddressEntry(branchEntry);
- if (!shippingAddressEntry) {
- const institutionEntry = getEntriesByType(entryData, 'institution')?.at(0);
- shippingAddressEntry = getShippingAddressEntry(institutionEntry);
- }
-
- const addressString = JSON.stringify(formatAddressEntryObject(
- shippingAddressEntry.addressComponents, pickupLocation
- ));
- return addressString;
- };
-
-
-
const submit = async submittedRecord => {
- // Separate top-level broker fields and ISO18626 transform inputs from the
- // fields that flow directly into illRequest.
- const {
- internalNote,
- identifiers = {},
- systemInstanceIdentifier,
- ...illRequestFields
- } = submittedRecord;
- const bibliographicItemId = ['ISBN', 'ISSN']
- .filter(code => identifiers[code])
- .map(code => ({
- bibliographicItemIdentifier: identifiers[code],
- bibliographicItemIdentifierCode: { '#text': code }
- }));
- const bibliographicRecordId = identifiers.OCLC ? [{
- bibliographicRecordIdentifier: identifiers.OCLC,
- bibliographicRecordIdentifierCode: { '#text': 'OCLC' }
- }] : [];
-
- const newRecord = {
- patron: illRequestFields?.patronInfo?.patronId,
- ...(internalNote && { internalNote }),
- illRequest: {
- ...illRequestFields,
- bibliographicInfo: {
- ...illRequestFields.bibliographicInfo,
- ...(bibliographicItemId.length > 0 && { bibliographicItemId }),
- ...(bibliographicRecordId.length > 0 && { bibliographicRecordId }),
- supplierUniqueRecordId: systemInstanceIdentifier,
- },
- },
+ const newRecord = formToBroker(submittedRecord);
+ // TODO: pending tiers, which will supply the level for the chosen tier.
+ // Create only: an edit leaves whatever level the request already carries.
+ newRecord.illRequest.serviceInfo = {
+ ...newRecord.illRequest.serviceInfo,
+ serviceLevel: { '#text': 'Standard' },
};
-
- const maximumCosts = newRecord.illRequest?.billingInfo?.maximumCosts;
-
- if (maximumCosts?.monetaryValue == null || maximumCosts.monetaryValue === '') {
- delete newRecord.illRequest?.billingInfo?.maximumCosts;
- } else {
- newRecord.illRequest.billingInfo.maximumCosts.currencyCode = { '#text': stripes.currency };
- }
-
try {
await creator.mutateAsync(newRecord);
} catch (err) {
@@ -336,15 +122,9 @@ const CreateRoute = () => {
>
diff --git a/ui-rs/src/routes/CreateRoute.test.js b/ui-rs/src/routes/CreateRoute.test.js
index 3e6f798..8804cfd 100644
--- a/ui-rs/src/routes/CreateRoute.test.js
+++ b/ui-rs/src/routes/CreateRoute.test.js
@@ -12,11 +12,7 @@ const mockOkapi = makeOkapiKyMock();
jest.mock('@folio/stripes-components/lib/Icon', () => require('../test/iconMock').default);
jest.mock('@folio/stripes-components/lib/TextArea', () => require('../test/textAreaMock').default);
-// Tiers not ported yet
-jest.mock('@folio/stripes/core', () => require('../test/stripesCore').makeStripesCoreMock(
- () => mockOkapi,
- { config: { ...require('../test/stripesCore').reshareConfigStub, useTiers: false } },
-));
+jest.mock('@folio/stripes/core', () => require('../test/stripesCore').makeStripesCoreMock(() => mockOkapi));
// CalloutContext is consumed by the route + stripes-reshare hooks; on the happy
// path sendCallout is never called, but provide it so an unexpected error path
@@ -43,6 +39,15 @@ const setField = (name, value) => fireEvent.change(
fieldByName(name), { target: { value } }
);
+// Everything the form validates before submit leaves its pristine/invalid state
+// (serviceType defaults to Loan, so it needs no filling).
+const fillRequiredFields = () => {
+ setField('patronInfo.givenName', 'Ada');
+ setField('patronInfo.surname', 'Lovelace');
+ setField('bibliographicInfo.title', 'Test Title');
+ setField('bibliographicInfo.author', 'Some Author');
+};
+
describe('CreateRoute', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -52,14 +57,10 @@ describe('CreateRoute', () => {
const history = createMemoryHistory({ initialEntries: ['/requests/create?foo=bar'] });
renderCreate({ history });
- // Submit is disabled while pristine; fill the required fields (serviceType
- // defaults to Loan) plus an ISBN to exercise the identifier transform.
- setField('patronInfo.givenName', 'Ada');
- setField('patronInfo.surname', 'Lovelace');
- setField('bibliographicInfo.title', 'Test Title');
- setField('bibliographicInfo.author', 'Some Author');
+ // Submit is disabled while pristine; fill the required fields plus an ISBN
+ // to exercise the identifier transform.
+ fillRequiredFields();
setField('identifiers.ISBN', '9781234567890');
- setField("serviceInfo.serviceLevel['#text']", 'Standard');
setField('internalNote', 'Staff only note');
fireEvent.click(document.querySelector('button[type="submit"]'));
@@ -102,12 +103,7 @@ describe('CreateRoute', () => {
renderCreate();
- setField('patronInfo.givenName', 'Ada');
- setField('patronInfo.surname', 'Lovelace');
- setField('bibliographicInfo.title', 'Test Title');
- setField('bibliographicInfo.author', 'Some Author');
- setField("serviceInfo.serviceLevel['#text']", 'Standard');
-
+ fillRequiredFields();
fireEvent.click(document.querySelector('button[type="submit"]'));
await waitFor(() => expect(mockOkapi.post).toHaveBeenCalledTimes(1));
diff --git a/ui-rs/src/routes/EditRoute.js b/ui-rs/src/routes/EditRoute.js
new file mode 100644
index 0000000..9716db5
--- /dev/null
+++ b/ui-rs/src/routes/EditRoute.js
@@ -0,0 +1,125 @@
+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 { CalloutContext } from '@folio/stripes/core';
+import { useCloseDirect, useOkapiKy, useOkapiQuery, upNLevels } from '@projectreshare/stripes-reshare';
+import PatronRequestForm from '../components/PatronRequestForm';
+import useOptions from '../components/PatronRequestForm/useOptions';
+import { brokerToForm, formToBroker } from '../components/PatronRequestForm/formMapping';
+import { EDIT } from '../components/PatronRequestForm/operations';
+import isRequestEditable from '../util/isRequestEditable';
+import handleSISelect from '../components/PatronRequestForm/handleSISelect';
+
+const EditRoute = ({ 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 close = useCloseDirect(requestView);
+
+ const { data: request, isSuccess: hasRequestLoaded } = useOkapiQuery(
+ `broker/patron_requests/${id}`,
+ { staleTime: 30 * 1000, notifyOnChangeProps: 'tracked' }
+ );
+
+ const { data: stateModel, isSuccess: hasModelLoaded } = useOkapiQuery(
+ `broker/state_model/models/${request?.stateModel}`,
+ { staleTime: 30 * 60 * 1000, cacheTime: 8 * 60 * 60 * 1000, enabled: hasRequestLoaded }
+ );
+
+ const { options, isSuccess: optionsLoaded } = useOptions();
+
+ const editor = useMutation({
+ mutationFn: (updatedRecord) => okapiKy
+ .put(`broker/patron_requests/${id}`, { json: updatedRecord }),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries(`broker/patron_requests/${id}`);
+ await queryClient.invalidateQueries('broker/patron_requests');
+ history.replace(requestView);
+ },
+ });
+
+ if (!hasRequestLoaded || !hasModelLoaded || !optionsLoaded) return null;
+
+ if (!isRequestEditable(stateModel, request)) {
+ return ;
+ }
+
+ const initialValues = brokerToForm(request);
+
+ const submit = async submittedRecord => {
+ const updatedRecord = formToBroker(submittedRecord, { operation: EDIT });
+ try {
+ await editor.mutateAsync(updatedRecord);
+ } catch (err) {
+ callout.sendCallout({
+ type: 'error',
+ message: (
+ }
+ value={err?.message || ''}
+ />
+ ),
+ });
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default EditRoute;
diff --git a/ui-rs/src/routes/EditRoute.test.js b/ui-rs/src/routes/EditRoute.test.js
new file mode 100644
index 0000000..e791734
--- /dev/null
+++ b/ui-rs/src/routes/EditRoute.test.js
@@ -0,0 +1,158 @@
+import React from 'react';
+import { Route } from 'react-router-dom';
+import { createMemoryHistory } from 'history';
+import { fireEvent, waitFor } from '@folio/jest-config-stripes/testing-library/react';
+
+import { renderWithRs } from '../test/renderWithRs';
+import { makeOkapiKyMock } from '../test/okapiKyMock';
+import EditRoute from './EditRoute';
+
+const mockOkapi = makeOkapiKyMock();
+
+jest.mock('@folio/stripes-components/lib/Icon', () => require('../test/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();
+
+// An editable request in the broker response shape.
+const editableRequest = (overrides = {}) => ({
+ id: 'req-1',
+ state: 'NEEDS_REVIEW',
+ stateModel: 'returnables',
+ internalNote: 'Staff only note',
+ illRequest: {
+ patronInfo: { patronId: 'p1', givenName: 'Ada', surname: 'Lovelace' },
+ serviceInfo: { serviceType: 'Loan' },
+ bibliographicInfo: {
+ title: 'Original Title',
+ author: 'Some Author',
+ bibliographicItemId: [
+ { bibliographicItemIdentifier: '9781234567890', bibliographicItemIdentifierCode: { '#text': 'ISBN' } },
+ { bibliographicItemIdentifier: 'M-2306-7118-7', bibliographicItemIdentifierCode: { '#text': 'ISMN' } },
+ ],
+ bibliographicRecordId: [
+ { bibliographicRecordIdentifier: 'oclc-1', bibliographicRecordIdentifierCode: { '#text': 'OCLC' } },
+ { bibliographicRecordIdentifier: 'lccn-9', bibliographicRecordIdentifierCode: { '#text': 'LCCN' } },
+ ],
+ supplierUniqueRecordId: 'sys-42',
+ },
+ },
+ ...overrides,
+});
+
+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;
+
+const returnablesModel = {
+ states: [
+ { side: 'REQUESTER', name: 'NEEDS_REVIEW', editable: true },
+ { side: 'REQUESTER', name: 'SENT' },
+ ],
+};
+
+const renderEdit = ({ request = editableRequest(), history } = {}) => {
+ mockOkapi.setResponses({
+ 'broker/patron_requests/req-1': request,
+ 'broker/state_model/models/returnables': returnablesModel,
+ });
+ return renderWithRs(
+
+
+ ,
+ { history: history ?? createMemoryHistory({ initialEntries: ['/requests/req-1/edit?foo=bar'] }) }
+ );
+};
+
+const fieldByName = (name) => Array.from(document.querySelectorAll('[name]'))
+ .find(el => el.getAttribute('name') === name);
+
+const setField = (name, value) => fireEvent.change(fieldByName(name), { target: { value } });
+
+describe('EditRoute', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('prefills from the existing request and PUTs the edited record', async () => {
+ const history = createMemoryHistory({ initialEntries: ['/requests/req-1/edit?foo=bar'] });
+ renderEdit({ history });
+
+ await waitFor(() => expect(fieldByName('bibliographicInfo.title')?.value).toBe('Original Title'));
+ expect(fieldByName('identifiers.ISBN').value).toBe('9781234567890');
+ expect(fieldByName('systemInstanceIdentifier').value).toBe('sys-42');
+
+ setField('bibliographicInfo.title', 'Edited Title');
+ fireEvent.click(document.querySelector('button[type="submit"]'));
+
+ await waitFor(() => expect(mockOkapi.put).toHaveBeenCalledTimes(1));
+
+ const [path, opts] = mockOkapi.put.mock.calls[0];
+ expect(path).toBe('broker/patron_requests/req-1');
+ expect(opts.json.illRequest.bibliographicInfo.title).toBe('Edited Title');
+ expect(itemIdFor(opts.json, 'ISBN')).toBe('9781234567890');
+ expect(itemIdFor(opts.json, 'ISMN')).toBe('M-2306-7118-7');
+ expect(recordIdFor(opts.json, 'OCLC')).toBe('oclc-1');
+ expect(recordIdFor(opts.json, 'LCCN')).toBe('lccn-9');
+ expect(opts.json.internalNote).toBe('Staff only note');
+
+ await waitFor(() => expect(history.location).toMatchObject({
+ pathname: '/requests/req-1',
+ search: '?foo=bar',
+ }));
+ expect(sendCallout).not.toHaveBeenCalled();
+ });
+
+ it('clearing a form identifier drops only that code, keeping the rest', async () => {
+ renderEdit();
+ await waitFor(() => expect(fieldByName('identifiers.ISBN')?.value).toBe('9781234567890'));
+
+ setField('identifiers.ISBN', '');
+ fireEvent.click(document.querySelector('button[type="submit"]'));
+
+ await waitFor(() => expect(mockOkapi.put).toHaveBeenCalledTimes(1));
+ const [, opts] = mockOkapi.put.mock.calls[0];
+ expect(itemIdFor(opts.json, 'ISBN')).toBeUndefined();
+ expect(itemIdFor(opts.json, 'ISMN')).toBe('M-2306-7118-7');
+ });
+
+ it('clears the internal note with an explicit empty string when emptied', async () => {
+ renderEdit();
+ await waitFor(() => expect(fieldByName('internalNote')?.value).toBe('Staff only note'));
+
+ setField('internalNote', '');
+ fireEvent.click(document.querySelector('button[type="submit"]'));
+
+ await waitFor(() => expect(mockOkapi.put).toHaveBeenCalledTimes(1));
+ expect(mockOkapi.put.mock.calls[0][1].json.internalNote).toBe('');
+ });
+
+ it('redirects away without a PUT when the request is not editable', async () => {
+ const history = createMemoryHistory({ initialEntries: ['/requests/req-1/edit?foo=bar'] });
+ renderEdit({ request: editableRequest({ state: 'SENT' }), history });
+
+ await waitFor(() => expect(history.location.pathname).toBe('/requests/req-1'));
+ expect(document.querySelector('button[type="submit"]')).toBeNull();
+ expect(mockOkapi.put).not.toHaveBeenCalled();
+ });
+
+ it('surfaces a failed PUT as an error callout and keeps the form mounted', async () => {
+ mockOkapi.put.mockRejectedValueOnce(new Error('Boom'));
+ renderEdit();
+
+ await waitFor(() => expect(fieldByName('bibliographicInfo.title')?.value).toBe('Original Title'));
+ setField('bibliographicInfo.title', 'Edited Title');
+ fireEvent.click(document.querySelector('button[type="submit"]'));
+
+ await waitFor(() => expect(mockOkapi.put).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(sendCallout).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'error' })
+ ));
+ expect(document.querySelector('button[type="submit"]')).not.toBeNull();
+ });
+});
diff --git a/ui-rs/src/routes/ViewRoute.js b/ui-rs/src/routes/ViewRoute.js
index e56fc99..acf8fa5 100644
--- a/ui-rs/src/routes/ViewRoute.js
+++ b/ui-rs/src/routes/ViewRoute.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useContext } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { Route, Switch } from 'react-router-dom';
import { Button, ButtonGroup, IconButton, Icon, Layout, Pane, PaneMenu, Paneset, Tooltip } from '@folio/stripes/components';
@@ -10,6 +10,8 @@ import ViewPatronRequest from '../components/ViewPatronRequest';
import { ChatPane } from '../components/chat';
import { useNotificationCounts } from '../components/chat/useNotifications';
import useRequestAside from '../util/useRequestAside';
+import isRequestEditable from '../util/isRequestEditable';
+import AppNameContext from '../AppNameContext';
import EditInternalNote from '../components/EditInternalNote';
import ManualClose from '../components/ManualClose';
import css from './ViewRoute.css';
@@ -29,18 +31,25 @@ const subheading = (req, params) => {
const ViewRoute = ({ location, location: { pathname }, match }) => {
const id = match.params?.id;
const intl = useIntl();
+ const appName = useContext(AppNameContext);
const close = useCloseDirect(upNLevels(location, 2));
const { data: request, isSuccess: hasRequestLoaded } = useOkapiQuery(
`broker/patron_requests/${id}`,
- { parseResponse: false, staleTime: 2 * 60 * 1000, notifyOnChangeProps: 'tracked' }
+ { staleTime: 2 * 60 * 1000, notifyOnChangeProps: 'tracked' }
);
const { data: actionsData } = useOkapiQuery(
`broker/patron_requests/${id}/actions`,
- { parseResponse: false, staleTime: 2 * 60 * 1000 }
+ { staleTime: 2 * 60 * 1000 }
);
const actions = actionsData?.actions ?? [];
+ const { data: stateModel } = useOkapiQuery(
+ `broker/state_model/models/${request?.stateModel}`,
+ { staleTime: 30 * 60 * 1000, cacheTime: 8 * 60 * 60 * 1000, enabled: appName === 'request' && hasRequestLoaded }
+ );
+ const canEdit = appName === 'request' && isRequestEditable(stateModel, request);
+
const { AsidePane, toggle, isOpen } = useRequestAside(ASIDE_SLOTS);
const { isSuccess: countsLoaded, unseen, total } = useNotificationCounts(request?.id);
const badgeCount = countsLoaded ? (unseen > 0 ? unseen : total) : undefined;
@@ -70,6 +79,19 @@ const ViewRoute = ({ location, location: { pathname }, match }) => {
dismissible
actionMenu={({ onToggle }) => (
<>
+ {canEdit &&
+
+
+
+
+
+ }
{
// RequestInfo detail values render from the fixture. The full id appears in
// both the card header and the fullId field, so allow more than one match.
expect(screen.getAllByText('pr-1').length).toBeGreaterThan(0);
- expect(screen.getByText('fixture-pickup')).toBeInTheDocument();
expect(screen.getByText('fixture-patron-note')).toBeInTheDocument();
expect(screen.getByText('fixture-id-type: fixture-id-value')).toBeInTheDocument();
@@ -150,11 +159,31 @@ describe('ViewRoute', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
- it('fetches request, actions, notifications, and events — and nothing else', async () => {
- renderViewRoute();
+ const renderInRequestApp = (request) => {
+ mockOkapi.setResponses({ ...responses, 'broker/patron_requests/pr-1': request });
+ return renderWithRs(
+
+
+ ,
+ { initialEntries: ['/requests/pr-1/details?sort=-dateCreated'], messages: messagesWithActions }
+ );
+ };
+
+ it('offers an Edit action linking to the edit route when the state is editable', async () => {
+ renderInRequestApp({ ...requestFixture, state: 'NEEDS_REVIEW', stateModel: 'returnables' });
await screen.findByText('Request REQ-101');
- const requestedPaths = new Set(mockOkapi.mock.calls.map(([path]) => path));
- expect(requestedPaths).toEqual(new Set(Object.keys(responses)));
+ fireEvent.click(screen.getByRole('button', { name: 'Actions' }));
+ const editItem = screen.getByRole('button', { name: 'ui-rs.editPatronRequest' });
+ expect(editItem).toBeInTheDocument();
+ expect(editItem.closest('a')).toHaveAttribute('href', expect.stringContaining('/requests/pr-1/edit'));
+ });
+
+ it('hides the Edit action when the state is not editable', async () => {
+ renderInRequestApp({ ...requestFixture, state: 'VALIDATED', stateModel: 'returnables' });
+ await screen.findByText('Request REQ-101');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Actions' }));
+ expect(screen.queryByRole('button', { name: 'ui-rs.editPatronRequest' })).not.toBeInTheDocument();
});
});
diff --git a/ui-rs/src/test/okapiKyMock.js b/ui-rs/src/test/okapiKyMock.js
index 07be369..71de0da 100644
--- a/ui-rs/src/test/okapiKyMock.js
+++ b/ui-rs/src/test/okapiKyMock.js
@@ -41,14 +41,11 @@ const makeOkapiKyMock = () => {
// Mutations (e.g. the create-request flow) call `okapiKy.post(path, { json })`
// directly rather than through `useOkapiQuery`, and read the created record back
// via `res.json()`. Expose post/put as jest.fns so the call + payload are
- // assertable; each resolves a ky-style response whose `.json()` yields the body.
- // `setPostResponse`/`setPutResponse` let a test override the returned body.
- let postBody = { id: 'new-1' };
- let putBody = {};
- okapiKy.post = jest.fn(async () => ({ json: async () => postBody }));
- okapiKy.put = jest.fn(async () => ({ json: async () => putBody }));
- okapiKy.setPostResponse = (body) => { postBody = body; };
- okapiKy.setPutResponse = (body) => { putBody = body; };
+ // assertable; each resolves a ky-style response whose `.json()` yields a body.
+ // A test needing a different body or a failure uses jest's own
+ // mockResolvedValueOnce/mockRejectedValueOnce.
+ okapiKy.post = jest.fn(async () => ({ json: async () => ({ id: 'new-1' }) }));
+ okapiKy.put = jest.fn(async () => ({ json: async () => ({}) }));
return okapiKy;
};
diff --git a/ui-rs/src/test/stripesCore.js b/ui-rs/src/test/stripesCore.js
index 2e61d99..8a407b5 100644
--- a/ui-rs/src/test/stripesCore.js
+++ b/ui-rs/src/test/stripesCore.js
@@ -4,21 +4,21 @@ import React from 'react';
// module, which only exists when the app is running. Route tests mock the module
// with `makeStripesCoreMock`, covering only the named exports our routes touch.
-// ReShare app-shell flags read via useStripes().config.reshare.
+// ReShare app-shell flags read via useStripes().config.reshare. One stub serves
+// every route test; a test needing different flags mocks useStripes itself.
const reshareConfigStub = {
showCost: true,
- useTiers: true,
sharedIndex: { type: 'folio', ui: 'https://shared-index.example' },
};
// `getOkapiKy` is a getter, not the ky mock itself: the jest.mock factory that
// calls this is hoisted above the test's module-scope `const mockOkapi = ...`, so
// the value must be read lazily (at render) rather than captured here.
-const makeStripesCoreMock = (getOkapiKy, { config = reshareConfigStub } = {}) => ({
+const makeStripesCoreMock = (getOkapiKy) => ({
useStripes: () => ({
currency: 'USD',
hasPerm: () => true,
- config: { reshare: config },
+ config: { reshare: reshareConfigStub },
}),
useOkapiKy: () => getOkapiKy(),
CalloutContext: React.createContext(null),
@@ -32,4 +32,4 @@ const makeStripesCoreMock = (getOkapiKy, { config = reshareConfigStub } = {}) =>
Pluggable: () => null,
});
-export { reshareConfigStub, makeStripesCoreMock };
+export { makeStripesCoreMock };
diff --git a/ui-rs/src/util/isRequestEditable.js b/ui-rs/src/util/isRequestEditable.js
new file mode 100644
index 0000000..6e3eb99
--- /dev/null
+++ b/ui-rs/src/util/isRequestEditable.js
@@ -0,0 +1,9 @@
+// Match the request against an editable requester-side state.
+const isRequestEditable = (stateModel, request) => {
+ if (!stateModel?.states || !request?.state) return false;
+ return stateModel.states.some(
+ s => s.side === 'REQUESTER' && s.name === request.state && s.editable === true
+ );
+};
+
+export default isRequestEditable;
diff --git a/ui-rs/src/util/tierForRequest.js b/ui-rs/src/util/tierForRequest.js
deleted file mode 100644
index f0bbdb3..0000000
--- a/ui-rs/src/util/tierForRequest.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Takes a request and list of tiers and returns the tier associated with that request
- * or undefined if none matches.
- */
-const tierForRequest = (request, tiers) => {
- return tiers?.find?.(t => t.level === request.serviceLevel?.value
- && t.cost === request.maximumCostsMonetaryValue
- && t.type === request.serviceType?.value);
-};
-
-export default tierForRequest;
diff --git a/ui-rs/src/util/tiersBySymbol.js b/ui-rs/src/util/tiersBySymbol.js
deleted file mode 100644
index cdf361c..0000000
--- a/ui-rs/src/util/tiersBySymbol.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * Takes a list of tiers as returned by the new directory and arranges them in an
- * object keyed by symbol with added label/value properties for use with Select.
- */
-const tiersBySymbol = (entries) => {
- return entries?.filter?.(item => item.type === 'institution')
- ?.reduce?.((acc, item) => {
- const formattedTiers = item.tiers?.map(tier => ({
- label: tier.name,
- value: tier.id,
- ...tier
- }));
-
- item.symbols?.forEach(sym => {
- if (sym?.authority && sym?.symbol) {
- const symbolKey = `${sym.authority}:${sym.symbol}`;
- acc[symbolKey] = formattedTiers;
- }
- });
-
- return acc;
- }, {});
-};
-
-export default tiersBySymbol;
diff --git a/ui-rs/src/util/useNewDirectoryEntries.js b/ui-rs/src/util/useNewDirectoryEntries.js
deleted file mode 100644
index 50bbb67..0000000
--- a/ui-rs/src/util/useNewDirectoryEntries.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import { useOkapiQuery, useSetting } from '@projectreshare/stripes-reshare';
-
-/** Fetches entries from the new directory from an array of symbol strings (the only parameter)
- * or, if absent, the symbol configured at the default_request_symbol AppSetting.
- *
- * Returns the react-query (which is only enabled if the router adapter setting is disabled)
- */
-const useNewDirectoryEntries = (symbols) => {
- const routingAdapterSetting = useSetting('routing_adapter');
- const defaultRequesterSymbolSetting = useSetting('default_request_symbol', 'requests');
- return useOkapiQuery('directory/entries', {
- searchParams: {
- maximumRecords: '1000',
- cql: `symbol any ${symbols ? symbols.join(' ') : [defaultRequesterSymbolSetting.value]}`
- },
- staleTime: 2 * 60 * 60 * 1000,
- enabled: routingAdapterSetting.isSuccess === true && routingAdapterSetting.value === 'disabled'
- });
-};
-
-export default useNewDirectoryEntries;
diff --git a/ui-rs/translations/ui-rs/en.json b/ui-rs/translations/ui-rs/en.json
index 64ea48b..6d23b47 100644
--- a/ui-rs/translations/ui-rs/en.json
+++ b/ui-rs/translations/ui-rs/en.json
@@ -69,6 +69,7 @@
"information.volume": "Volume",
"createPatronRequest": "Create patron request",
"create.error": "Error creating patron request",
+ "edit.error": "Error editing patron request",
"rerequestPatronRequest": "Create revised request",
"revalidatePatronRequest": "Resubmit request",
"closeNewPatronRequest": "Close new patron request",