From e699fb152c17b03f3fb590352059cdb60aa98008 Mon Sep 17 00:00:00 2001 From: Jason Skomorowski Date: Thu, 30 Jul 2026 18:47:17 -0400 Subject: [PATCH] Allow editing in editable states ILLDEV-455 Also removes a lot of mod-rs era stuff --- .../PatronRequestForm/PatronRequestForm.js | 179 ++---------- .../PatronRequestForm/formMapping.js | 84 ++++++ .../PatronRequestForm/handleSISelect.js | 55 ++++ .../PatronRequestForm/operations.js | 4 + .../PatronRequestForm/useOptions.js | 20 ++ ui-rs/src/components/ViewMessageBanners.js | 2 +- .../sections/EventHistory/EventHistory.js | 1 - ui-rs/src/index.js | 4 + ui-rs/src/routes/CreateRoute.js | 256 ++---------------- ui-rs/src/routes/CreateRoute.test.js | 32 +-- ui-rs/src/routes/EditRoute.js | 125 +++++++++ ui-rs/src/routes/EditRoute.test.js | 158 +++++++++++ ui-rs/src/routes/ViewRoute.js | 28 +- ui-rs/src/routes/ViewRoute.test.js | 45 ++- ui-rs/src/test/okapiKyMock.js | 13 +- ui-rs/src/test/stripesCore.js | 10 +- ui-rs/src/util/isRequestEditable.js | 9 + ui-rs/src/util/tierForRequest.js | 11 - ui-rs/src/util/tiersBySymbol.js | 25 -- ui-rs/src/util/useNewDirectoryEntries.js | 21 -- ui-rs/translations/ui-rs/en.json | 1 + 21 files changed, 586 insertions(+), 497 deletions(-) create mode 100644 ui-rs/src/components/PatronRequestForm/formMapping.js create mode 100644 ui-rs/src/components/PatronRequestForm/handleSISelect.js create mode 100644 ui-rs/src/components/PatronRequestForm/operations.js create mode 100644 ui-rs/src/components/PatronRequestForm/useOptions.js create mode 100644 ui-rs/src/routes/EditRoute.js create mode 100644 ui-rs/src/routes/EditRoute.test.js create mode 100644 ui-rs/src/util/isRequestEditable.js delete mode 100644 ui-rs/src/util/tierForRequest.js delete mode 100644 ui-rs/src/util/tiersBySymbol.js delete mode 100644 ui-rs/src/util/useNewDirectoryEntries.js 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 ( + +
+ {({ form, handleSubmit, pristine, submitting, submitSucceeded }) => ( + + + + } + renderEnd={ + + } + /> + } + paneTitle={} + > + + + + + {prompt => } + +
+ )} + +
+ ); +}; + +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",