From 87c86f74496e582b95c14990d63795d389ce0297 Mon Sep 17 00:00:00 2001 From: schefter Date: Thu, 15 Oct 2026 09:38:13 +0200 Subject: [PATCH 1/5] UIOR-1556 Mirror the backend export matching The "Automatic export" checkbox was calculated account-first, which predates integrations without account numbers. Mirror the rule of mod-data-export-worker instead: a non-default integration with an empty account list matches all of the vendor's lines, and the default integration excludes only accounts listed on other integrations. Match ordering integrations only. Claiming integrations share the vendorEdiOrdersExportConfig shape and were matched as well, so a claiming config could toggle the checkbox and narrow the default ordering integration. Expose getApplicableOrderingIntegrations so the UI can show which integrations apply. --- src/components/Utils/toggleAutomaticExport.js | 97 ++++++++++++------- .../Utils/toggleAutomaticExport.test.js | 97 ++++++++++++++++--- test/jest/fixtures/integrationConfig.js | 1 + 3 files changed, 145 insertions(+), 50 deletions(-) diff --git a/src/components/Utils/toggleAutomaticExport.js b/src/components/Utils/toggleAutomaticExport.js index d68067ad8..423a50eb6 100644 --- a/src/components/Utils/toggleAutomaticExport.js +++ b/src/components/Utils/toggleAutomaticExport.js @@ -1,38 +1,67 @@ -const automaticExport = 'automaticExport'; - -const isAcqMethodIncluded = (config, acquisitionMethod) => ( - Boolean(config - ?.exportTypeSpecificParameters - ?.vendorEdiOrdersExportConfig - ?.ediConfig - ?.defaultAcquisitionMethods - ?.includes(acquisitionMethod)) +import { FOLIO_EXPORT_TYPE } from '@folio/stripes-acq-components'; + +const AUTOMATIC_EXPORT_FIELD = 'automaticExport'; + +export const getOrderingConfig = (config) => config?.exportTypeSpecificParameters?.vendorEdiOrdersExportConfig; + +export const formatConfigLabel = (config) => { + const { configName, transmissionMethod } = getOrderingConfig(config) || {}; + + return transmissionMethod ? `${configName} (${transmissionMethod})` : configName; +}; + +const isAcqMethodIncluded = (orderingConfig, acquisitionMethod) => ( + Boolean(orderingConfig?.ediConfig?.defaultAcquisitionMethods?.includes(acquisitionMethod)) ); +/** + * Mirrors the PO line matching of mod-data-export-worker + * (`MapToEdifactOrdersTasklet#getPoLineQuery`, `#getVendorAccountFilter`) so + * the UI can predict which ordering integration picks up a PO line. A config + * applies when the acquisition method is in its defaultAcquisitionMethods and: + * - non-default, accountNoList not empty: vendorAccount is in the list + * - non-default, accountNoList empty: every line + * - default, vendor has several ordering configs: vendorAccount is in none + * of their account lists + * - default, single ordering config: every line + * Without an acquisition method the backend job fails, so nothing applies. + * + * KEEP IN SYNC with the backend rule. + */ +export const getApplicableOrderingIntegrations = ({ vendorAccount, acquisitionMethod, integrationConfigs = [] }) => { + if (!acquisitionMethod) return []; + + // Claiming configs share the vendorEdiOrdersExportConfig shape but never export PO lines. + const orderingConfigs = integrationConfigs.filter(config => ( + config?.type === FOLIO_EXPORT_TYPE.EDIFACT_ORDERS_EXPORT && getOrderingConfig(config) + )); + + const hasMultipleConfigs = orderingConfigs.length > 1; + const accountAntiList = hasMultipleConfigs + ? orderingConfigs.flatMap(config => getOrderingConfig(config)?.ediConfig?.accountNoList ?? []) + : []; + + return orderingConfigs.filter((config) => { + const orderingConfig = getOrderingConfig(config); + + if (!isAcqMethodIncluded(orderingConfig, acquisitionMethod)) return false; + + if (orderingConfig.isDefaultConfig) { + return !hasMultipleConfigs || !accountAntiList.includes(vendorAccount); + } + + const accountNoList = orderingConfig.ediConfig?.accountNoList ?? []; + + return accountNoList.length === 0 || accountNoList.includes(vendorAccount); + }); +}; + export const toggleAutomaticExport = ({ vendorAccount, acquisitionMethod, integrationConfigs, change }) => { - if (vendorAccount) { - const integrationConfig = integrationConfigs.find(config => ( - config - ?.exportTypeSpecificParameters - ?.vendorEdiOrdersExportConfig - ?.ediConfig - ?.accountNoList - ?.includes(vendorAccount) - )); - - return integrationConfig - ? change(automaticExport, isAcqMethodIncluded(integrationConfig, acquisitionMethod)) - : change(automaticExport, false); - } else { - const defaultIntegrationConfig = integrationConfigs.find(config => ( - config - ?.exportTypeSpecificParameters - ?.vendorEdiOrdersExportConfig - ?.isDefaultConfig - )); - - return defaultIntegrationConfig - ? change(automaticExport, isAcqMethodIncluded(defaultIntegrationConfig, acquisitionMethod)) - : change(automaticExport, false); - } + const applicableIntegrations = getApplicableOrderingIntegrations({ + vendorAccount, + acquisitionMethod, + integrationConfigs, + }); + + change(AUTOMATIC_EXPORT_FIELD, applicableIntegrations.length > 0); }; diff --git a/src/components/Utils/toggleAutomaticExport.test.js b/src/components/Utils/toggleAutomaticExport.test.js index 52e604354..d6cbecf9f 100644 --- a/src/components/Utils/toggleAutomaticExport.test.js +++ b/src/components/Utils/toggleAutomaticExport.test.js @@ -1,5 +1,8 @@ import { integrationConfig } from '../../../test/jest/fixtures'; -import { toggleAutomaticExport } from './toggleAutomaticExport'; +import { + getApplicableOrderingIntegrations, + toggleAutomaticExport, +} from './toggleAutomaticExport'; const change = jest.fn(); const acquisitionMethod = integrationConfig @@ -19,6 +22,13 @@ const args = { change, }; +const buildConfig = (type, vendorEdiOrdersExportConfig) => ({ + type, + exportTypeSpecificParameters: { vendorEdiOrdersExportConfig }, +}); +const orderingConfig = (config) => buildConfig('EDIFACT_ORDERS_EXPORT', config); +const claimingConfig = (config) => buildConfig('CLAIMS', { integrationType: 'Claiming', ...config }); + describe('toggleAutomaticExport', () => { beforeEach(() => { change.mockClear(); @@ -43,16 +53,12 @@ describe('toggleAutomaticExport', () => { toggleAutomaticExport({ ...args, vendorAccount: null, - integrationConfigs: [{ - exportTypeSpecificParameters: { - vendorEdiOrdersExportConfig: { - isDefaultConfig: true, - ediConfig: { - defaultAcquisitionMethods: [acquisitionMethod], - }, - }, + integrationConfigs: [orderingConfig({ + isDefaultConfig: true, + ediConfig: { + defaultAcquisitionMethods: [acquisitionMethod], }, - }], + })], }); expect(change).toBeCalledWith('automaticExport', true); @@ -62,15 +68,74 @@ describe('toggleAutomaticExport', () => { toggleAutomaticExport({ ...args, vendorAccount: null, - integrationConfigs: [{ - exportTypeSpecificParameters: { - vendorEdiOrdersExportConfig: { - isDefaultConfig: true, - }, + integrationConfigs: [orderingConfig({ + isDefaultConfig: true, + })], + }); + + expect(change).toBeCalledWith('automaticExport', false); + }); + + it('should set automatic export to checked for an account-less integration (matches all)', () => { + toggleAutomaticExport({ + ...args, + vendorAccount: 'anyAccount', + integrationConfigs: [orderingConfig({ + ediConfig: { + defaultAcquisitionMethods: [acquisitionMethod], + accountNoList: [], + }, + })], + }); + + expect(change).toBeCalledWith('automaticExport', true); + }); + + it('should set automatic export to unchecked when no acquisition method is selected', () => { + toggleAutomaticExport({ + ...args, + acquisitionMethod: undefined, + }); + + expect(change).toBeCalledWith('automaticExport', false); + }); + + it('should ignore a claiming integration even if account and acquisition method match', () => { + toggleAutomaticExport({ + ...args, + integrationConfigs: [claimingConfig({ + ediConfig: { + defaultAcquisitionMethods: [acquisitionMethod], + accountNoList: [vendorAccount], }, - }], + })], }); expect(change).toBeCalledWith('automaticExport', false); }); }); + +describe('getApplicableOrderingIntegrations', () => { + it('should not let a claiming integration narrow the default ordering integration', () => { + const defaultOrdering = orderingConfig({ + isDefaultConfig: true, + ediConfig: { + defaultAcquisitionMethods: [acquisitionMethod], + }, + }); + const claiming = claimingConfig({ + ediConfig: { + defaultAcquisitionMethods: [acquisitionMethod], + accountNoList: [vendorAccount], + }, + }); + + const applicable = getApplicableOrderingIntegrations({ + vendorAccount, + acquisitionMethod, + integrationConfigs: [claiming, defaultOrdering], + }); + + expect(applicable).toEqual([defaultOrdering]); + }); +}); diff --git a/test/jest/fixtures/integrationConfig.js b/test/jest/fixtures/integrationConfig.js index 8ba33304e..4a5452683 100644 --- a/test/jest/fixtures/integrationConfig.js +++ b/test/jest/fixtures/integrationConfig.js @@ -1,5 +1,6 @@ export const integrationConfig = { id: 'e9b57fab-1850-44d4-8499-71fd15c845a0', + type: 'EDIFACT_ORDERS_EXPORT', exportTypeSpecificParameters: { vendorEdiOrdersExportConfig: { vendorId: 'f0b57fab-1850-44d4-8499-71fd15c845tb', From f9bcfc3feb4ce6ba5a6f1be16bf49bed2bd1c31b Mon Sep 17 00:00:00 2001 From: schefter Date: Thu, 15 Oct 2026 09:38:13 +0200 Subject: [PATCH 2/5] UIOR-1556 Show the applicable export integration The checkbox only tells the orderer that a line is flagged for export, not which integration picks it up. Add a read-only hint next to the checkbox in the edit form and the detail view: the integration name with its transmission method for a single match, an info popover listing the candidates for several matches, and a warning icon when no integration matches while the checkbox is on. The detail view fetches the integration configs only when the hint can show something and hides it while they load, so the warning does not flash before the data arrives. Manual orders are never exported, so the hint is hidden for them and the checkbox is no longer auto-toggled. The detail view now carries the same explanatory popover as the edit form. --- CHANGELOG.md | 1 + .../DetailsFields/AutomaticExportInfo.css | 20 ++++ .../DetailsFields/AutomaticExportInfo.js | 105 ++++++++++++++++++ .../DetailsFields/FieldAutomaticExport.js | 4 +- src/common/POLFields/index.js | 1 + .../POLine/POLineDetails/POLineDetails.js | 33 +++++- .../POLine/POLineDetails/POLineDetailsForm.js | 29 ++++- src/components/POLine/POLineView.js | 12 ++ src/components/POLine/Vendor/VendorForm.js | 7 +- translations/ui-orders/en.json | 4 + 10 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 src/common/POLFields/DetailsFields/AutomaticExportInfo.css create mode 100644 src/common/POLFields/DetailsFields/AutomaticExportInfo.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c223ef9fd..93a2ebfac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Multi-year prepayments fixes and improvements. Refs UIOR-1572. * Integrate the Connected Tasks/Jobs plugin with Purchase Order and PO Line detail views. Refs UIOR-1578. * Set initial sorting column for "Related invoice lines" MCL. Refs UIOR-1581. +* Show which export integration applies to a PO line. Refs UIOR-1556. ## [9.0.5](https://github.com/folio-org/ui-orders/tree/v9.0.5) (2026-07-20) [Full Changelog](https://github.com/folio-org/ui-orders/compare/v9.0.4...v9.0.5) diff --git a/src/common/POLFields/DetailsFields/AutomaticExportInfo.css b/src/common/POLFields/DetailsFields/AutomaticExportInfo.css new file mode 100644 index 000000000..ccf8e7f35 --- /dev/null +++ b/src/common/POLFields/DetailsFields/AutomaticExportInfo.css @@ -0,0 +1,20 @@ +/* Two lines, then an ellipsis. `anywhere` lets a long name without spaces wrap + instead of being clipped mid-word. */ +.exportVia { + display: -webkit-box; + overflow: hidden; + margin-bottom: 1rem; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow-wrap: anywhere; +} + +.warningButton { + color: var(--warn); +} + +.list { + margin: 0 0 0.75rem; + padding-inline-start: 1.1rem; +} diff --git a/src/common/POLFields/DetailsFields/AutomaticExportInfo.js b/src/common/POLFields/DetailsFields/AutomaticExportInfo.js new file mode 100644 index 000000000..31b181181 --- /dev/null +++ b/src/common/POLFields/DetailsFields/AutomaticExportInfo.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { + IconButton, + InfoPopover, +} from '@folio/stripes/components'; + +import { + formatConfigLabel, + getApplicableOrderingIntegrations, +} from '../../../components/Utils/toggleAutomaticExport'; +import css from './AutomaticExportInfo.css'; + +// Hint for the "Automatic export" checkbox. placement="label" renders an icon +// next to the label (warning: no match, info popover: several matches), +// placement="below" renders the single matching integration as a text line. +// Without integrationConfigs (order templates) there is nothing to match. +const AutomaticExportInfo = ({ + acquisitionMethod, + automaticExport = false, + integrationConfigs, + isLoading = false, + manualOrder = false, + placement = 'below', + vendorAccount, +}) => { + const intl = useIntl(); + + if (manualOrder || !automaticExport || isLoading || !integrationConfigs) return null; + + const applicableIntegrations = getApplicableOrderingIntegrations({ + vendorAccount, + acquisitionMethod, + integrationConfigs, + }); + + if (placement === 'below') { + if (applicableIntegrations.length !== 1) return null; + + const label = formatConfigLabel(applicableIntegrations[0]); + + return ( +
+ +
+ ); + } + + if (applicableIntegrations.length === 0) { + return ( + } + renderTrigger={({ open, ref, toggle }) => ( + + )} + /> + ); + } + + if (applicableIntegrations.length > 1) { + return ( + + + + +
    + {applicableIntegrations.map((config) => ( +
  • {formatConfigLabel(config)}
  • + ))} +
+ + + )} + /> + ); + } + + return null; +}; + +AutomaticExportInfo.propTypes = { + acquisitionMethod: PropTypes.string, + automaticExport: PropTypes.bool, + integrationConfigs: PropTypes.arrayOf(PropTypes.object), + isLoading: PropTypes.bool, + manualOrder: PropTypes.bool, + placement: PropTypes.oneOf(['label', 'below']), + vendorAccount: PropTypes.string, +}; + +export default AutomaticExportInfo; diff --git a/src/common/POLFields/DetailsFields/FieldAutomaticExport.js b/src/common/POLFields/DetailsFields/FieldAutomaticExport.js index d4d2705cb..027effc71 100644 --- a/src/common/POLFields/DetailsFields/FieldAutomaticExport.js +++ b/src/common/POLFields/DetailsFields/FieldAutomaticExport.js @@ -9,11 +9,12 @@ import { import { POL_FORM_FIELDS } from '../../constants'; -const FieldAutomaticExport = ({ isManualOrder = false, ...props }) => { +const FieldAutomaticExport = ({ isManualOrder = false, exportInfo = null, ...props }) => { const label = ( <> {isManualOrder && } />} + {exportInfo} ); @@ -32,6 +33,7 @@ const FieldAutomaticExport = ({ isManualOrder = false, ...props }) => { }; FieldAutomaticExport.propTypes = { + exportInfo: PropTypes.node, isManualOrder: PropTypes.bool, }; diff --git a/src/common/POLFields/index.js b/src/common/POLFields/index.js index 0682f3da6..89d553d5e 100644 --- a/src/common/POLFields/index.js +++ b/src/common/POLFields/index.js @@ -24,6 +24,7 @@ export { default as FieldCheckInItems } from './DetailsFields/FieldCheckInItems' export { default as FieldCancellationRestrictionNote } from './DetailsFields/FieldCancellationRestrictionNote'; export { default as FieldPOLineDescription } from './DetailsFields/FieldPOLineDescription'; export { default as FieldAutomaticExport } from './DetailsFields/FieldAutomaticExport'; +export { default as AutomaticExportInfo } from './DetailsFields/AutomaticExportInfo'; // vendor export { default as FieldVendorInstructions } from './VendorFields/FieldVendorInstructions'; diff --git a/src/components/POLine/POLineDetails/POLineDetails.js b/src/components/POLine/POLineDetails/POLineDetails.js index 53431c174..926c59ea7 100644 --- a/src/components/POLine/POLineDetails/POLineDetails.js +++ b/src/components/POLine/POLineDetails/POLineDetails.js @@ -7,6 +7,7 @@ import { ClipCopy } from '@folio/stripes/smart-components'; import { Checkbox, Col, + InfoPopover, KeyValue, Loading, NoValue, @@ -25,6 +26,7 @@ import { PAYMENT_STATUS_TRANSLATED_VALUES, RECEIPT_STATUS_TRANSLATED_VALUES, } from '../../../common/constants'; +import { AutomaticExportInfo } from '../../../common/POLFields'; import { useAcqMethod } from '../../../common/hooks'; const invalidAcqMethod = ; @@ -51,12 +53,24 @@ const DEFAULT_LINE = {}; const POLineDetails = ({ hiddenFields = DEFAULT_HIDDEN_FIELDS, + integrationConfigs, + isIntegrationConfigsLoading = false, line = DEFAULT_LINE, + manualOrder = false, }) => { const intl = useIntl(); const receiptDate = get(line, 'receiptDate'); const { acqMethod, isLoading } = useAcqMethod(line.acquisitionMethod); + const exportInfoProps = { + acquisitionMethod: line.acquisitionMethod, + automaticExport: line.automaticExport, + integrationConfigs, + isLoading: isIntegrationConfigsLoading, + manualOrder, + vendorAccount: line.vendorDetail?.vendorAccount, + }; + return ( <> @@ -99,9 +113,23 @@ const POLineDetails = ({ } + label={( + <> + + {manualOrder && } />} + + + )} vertical /> + + @@ -342,7 +370,10 @@ const POLineDetails = ({ POLineDetails.propTypes = { hiddenFields: PropTypes.object, + integrationConfigs: PropTypes.arrayOf(PropTypes.object), + isIntegrationConfigsLoading: PropTypes.bool, line: PropTypes.object, + manualOrder: PropTypes.bool, }; export default POLineDetails; diff --git a/src/components/POLine/POLineDetails/POLineDetailsForm.js b/src/components/POLine/POLineDetails/POLineDetailsForm.js index 7348240f5..d0a6547f8 100644 --- a/src/components/POLine/POLineDetails/POLineDetailsForm.js +++ b/src/components/POLine/POLineDetails/POLineDetailsForm.js @@ -21,6 +21,7 @@ import { FieldPOLineNumber, FieldAcquisitionMethod, FieldAutomaticExport, + AutomaticExportInfo, FieldOrderFormat, FieldReceiptDate, FieldDonor, @@ -52,7 +53,6 @@ import { isReceiptNotRequired } from '../utils'; const defaultProps = { hiddenFields: {}, - integrationConfigs: [], order: {}, vendor: {}, }; @@ -64,7 +64,7 @@ function POLineDetailsForm({ formValues, hiddenFields = defaultProps.hiddenFields, initialValues: poLine, - integrationConfigs = defaultProps.integrationConfigs, + integrationConfigs, order = defaultProps.order, vendor = defaultProps.vendor, }) { @@ -96,10 +96,14 @@ function POLineDetailsForm({ const onAcqMethodChange = useCallback( (value) => { change(POL_FORM_FIELDS.acquisitionMethod, value); + + // Manual orders are never exported, so leave the disabled checkbox alone. + if (isManualOrder) return; + const vendorAccount = formValues?.vendorDetail?.vendorAccount; toggleAutomaticExport({ vendorAccount, acquisitionMethod: value, integrationConfigs, change }); - }, [change, formValues, integrationConfigs], + }, [change, formValues, integrationConfigs, isManualOrder], ); const onReceiptStatusChange = useCallback(async ({ target: { value } }) => { @@ -160,6 +164,14 @@ function POLineDetailsForm({ } }, [change, initReceivingWorkflowChange, isPostPendingOrder]); + const exportInfoProps = { + acquisitionMethod: formValues?.acquisitionMethod, + automaticExport: formValues?.automaticExport, + integrationConfigs, + manualOrder: isManualOrder, + vendorAccount: formValues?.vendorDetail?.vendorAccount, + }; + return ( <> @@ -196,6 +208,17 @@ function POLineDetailsForm({ + )} + /> + + diff --git a/src/components/POLine/POLineView.js b/src/components/POLine/POLineView.js index 4a68c6804..35aeb5e49 100644 --- a/src/components/POLine/POLineView.js +++ b/src/components/POLine/POLineView.js @@ -53,6 +53,7 @@ import { RoutingListAccordion, TagsBadge, useAcqRestrictions, + useIntegrationConfigs, useModalToggle, useShowCallout, VersionHistoryButton, @@ -186,6 +187,14 @@ const POLineView = ({ const isCancelable = isCancelableLine(line, order); + // Fetched only when the export hint can show something. + const { + integrationConfigs, + isFetching: isIntegrationConfigsFetching, + } = useIntegrationConfigs({ + organizationId: (line.automaticExport && !order?.manualPo) ? order?.vendor : undefined, + }); + const { hasLocationRestrictedFund } = useIsFundsRestrictedByLocationIds(line); useEffect(() => { @@ -439,6 +448,9 @@ const POLineView = ({ {isOngoing(order.orderType) && ( diff --git a/src/components/POLine/Vendor/VendorForm.js b/src/components/POLine/Vendor/VendorForm.js index 849386176..645364663 100644 --- a/src/components/POLine/Vendor/VendorForm.js +++ b/src/components/POLine/Vendor/VendorForm.js @@ -37,6 +37,7 @@ const VendorForm = ({ const { vendorDetail } = getState().values; const currentAccountNumber = vendorDetail?.vendorAccount; const isPostPendingOrder = !isWorkflowStatusIsPending(order); + const isManualOrder = Boolean(order?.manualPo); const initialAccountNumber = useRef(currentAccountNumber); const activeAccountOptions = useMemo(() => { @@ -57,10 +58,14 @@ const VendorForm = ({ const onAccountChange = useCallback( ({ target: { value } }) => { change('vendorDetail.vendorAccount', value); + + // Manual orders are never exported, so leave the disabled checkbox alone. + if (isManualOrder) return; + const acquisitionMethod = getState().values?.acquisitionMethod; toggleAutomaticExport({ vendorAccount: value, acquisitionMethod, integrationConfigs, change }); - }, [change, getState, integrationConfigs], + }, [change, getState, integrationConfigs, isManualOrder], ); const isSelectedAccountInactive = useMemo(() => { diff --git a/translations/ui-orders/en.json b/translations/ui-orders/en.json index 340ecd8c5..963d3c0a7 100644 --- a/translations/ui-orders/en.json +++ b/translations/ui-orders/en.json @@ -533,6 +533,10 @@ "poLine.acquisitionMethod": "Acquisition method", "poLine.administrativeData": "POL admin data", "poLine.automaticExport": "Automatic export", + "poLine.automaticExport.exportVia": "Export via: {name}", + "poLine.automaticExport.multipleTitle": "Multiple integrations:", + "poLine.automaticExport.multipleHint": "Only the next scheduled integration handles the export.", + "poLine.automaticExport.noIntegration": "No matching integration configured", "poLine.cancellationRestriction": "Cancellation restriction", "poLine.cancellationRestrictionNote": "Cancellation description", "poLine.claimingActive": "Claiming active", From 6513cc014b1e5621f617e1171fcd673c76bb5f25 Mon Sep 17 00:00:00 2001 From: schefter Date: Thu, 15 Oct 2026 09:38:13 +0200 Subject: [PATCH 3/5] UIOR-1556 Add tests for the export hint --- .../DetailsFields/AutomaticExportInfo.test.js | 158 ++++++++++++++++++ .../FieldAutomaticExport.test.js | 13 ++ .../POLineDetails/POLineDetails.test.js | 42 +++++ .../POLineDetails/POLineDetailsForm.test.js | 43 +++++ src/components/POLine/POLineView.test.js | 33 +++- .../POLine/Vendor/VendorForm.test.js | 39 +++++ 6 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/common/POLFields/DetailsFields/AutomaticExportInfo.test.js diff --git a/src/common/POLFields/DetailsFields/AutomaticExportInfo.test.js b/src/common/POLFields/DetailsFields/AutomaticExportInfo.test.js new file mode 100644 index 000000000..453e58d48 --- /dev/null +++ b/src/common/POLFields/DetailsFields/AutomaticExportInfo.test.js @@ -0,0 +1,158 @@ +import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; + +import { integrationConfig } from 'fixtures'; +import AutomaticExportInfo from './AutomaticExportInfo'; + +const acquisitionMethod = integrationConfig + .exportTypeSpecificParameters + .vendorEdiOrdersExportConfig + .ediConfig + .defaultAcquisitionMethods[0]; +const vendorAccount = integrationConfig + .exportTypeSpecificParameters + .vendorEdiOrdersExportConfig + .ediConfig + .accountNoList[0]; + +const secondConfig = { + ...integrationConfig, + id: 'second-config', + exportTypeSpecificParameters: { + vendorEdiOrdersExportConfig: { + ...integrationConfig.exportTypeSpecificParameters.vendorEdiOrdersExportConfig, + configName: 'secondConfig', + transmissionMethod: 'Email', + }, + }, +}; + +const defaultProps = { + acquisitionMethod, + automaticExport: true, + integrationConfigs: [integrationConfig], + vendorAccount, +}; + +const renderAutomaticExportInfo = (props = {}) => render( + , +); + +describe('AutomaticExportInfo', () => { + describe('placement "below"', () => { + it('should render the single matching integration with its full label as title', () => { + renderAutomaticExportInfo({ placement: 'below' }); + + const hint = screen.getByText('ui-orders.poLine.automaticExport.exportVia'); + + expect(hint).toBeInTheDocument(); + expect(hint.closest('[title]')).toHaveAttribute('title', 'testConfig'); + }); + + it('should include the transmission method in the label', () => { + renderAutomaticExportInfo({ + placement: 'below', + integrationConfigs: [secondConfig], + }); + + expect(screen.getByText('ui-orders.poLine.automaticExport.exportVia').closest('[title]')) + .toHaveAttribute('title', 'secondConfig (Email)'); + }); + + it('should render nothing when no integration matches', () => { + const { container } = renderAutomaticExportInfo({ + placement: 'below', + integrationConfigs: [], + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render nothing when several integrations match', () => { + const { container } = renderAutomaticExportInfo({ + placement: 'below', + integrationConfigs: [integrationConfig, secondConfig], + }); + + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe('placement "label"', () => { + it('should render a warning icon when no integration matches', async () => { + renderAutomaticExportInfo({ + placement: 'label', + integrationConfigs: [], + }); + + const warning = screen.getByRole('button', { name: 'ui-orders.poLine.automaticExport.noIntegration' }); + + await userEvent.click(warning); + + expect(warning).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('ui-orders.poLine.automaticExport.noIntegration')).toBeInTheDocument(); + }); + + it('should render an info popover listing all matching integrations', async () => { + renderAutomaticExportInfo({ + placement: 'label', + integrationConfigs: [integrationConfig, secondConfig], + }); + + await userEvent.click(screen.getByRole('button')); + + expect(screen.getByText('ui-orders.poLine.automaticExport.multipleTitle')).toBeInTheDocument(); + expect(screen.getByText('testConfig')).toBeInTheDocument(); + expect(screen.getByText('secondConfig (Email)')).toBeInTheDocument(); + expect(screen.getByText('ui-orders.poLine.automaticExport.multipleHint')).toBeInTheDocument(); + }); + + it('should render nothing when exactly one integration matches', () => { + const { container } = renderAutomaticExportInfo({ placement: 'label' }); + + expect(container).toBeEmptyDOMElement(); + }); + }); + + it('should render nothing when automatic export is off', () => { + const { container } = renderAutomaticExportInfo({ + placement: 'label', + automaticExport: false, + integrationConfigs: [], + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render nothing without integration configs (order templates)', () => { + const { container } = renderAutomaticExportInfo({ + placement: 'label', + integrationConfigs: undefined, + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render nothing while the integration configs are loading', () => { + const { container } = renderAutomaticExportInfo({ + placement: 'label', + isLoading: true, + integrationConfigs: [], + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render nothing for a manual order', () => { + const { container } = renderAutomaticExportInfo({ + placement: 'label', + manualOrder: true, + integrationConfigs: [], + }); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/common/POLFields/DetailsFields/FieldAutomaticExport.test.js b/src/common/POLFields/DetailsFields/FieldAutomaticExport.test.js index 0885c3171..e1ba4bf5a 100644 --- a/src/common/POLFields/DetailsFields/FieldAutomaticExport.test.js +++ b/src/common/POLFields/DetailsFields/FieldAutomaticExport.test.js @@ -21,4 +21,17 @@ describe('FieldAutomaticExport', () => { expect(screen.getByText('ui-orders.poLine.automaticExport')).toBeInTheDocument(); }); + + it('should render the manual order info for a manual order', () => { + renderFieldAutomaticExport({ isManualOrder: true }); + + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /ui-orders.poLine.automaticExport/ })).toBeInTheDocument(); + }); + + it('should render the export info inside the label', () => { + renderFieldAutomaticExport({ exportInfo: export info }); + + expect(screen.getByRole('checkbox', { name: /export info/ })).toBeInTheDocument(); + }); }); diff --git a/src/components/POLine/POLineDetails/POLineDetails.test.js b/src/components/POLine/POLineDetails/POLineDetails.test.js index 88d52103a..fe2b137f8 100644 --- a/src/components/POLine/POLineDetails/POLineDetails.test.js +++ b/src/components/POLine/POLineDetails/POLineDetails.test.js @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from 'react-query'; import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; import { ACQUISITION_METHOD } from '@folio/stripes-acq-components'; +import { integrationConfig } from 'fixtures'; import POLineDetails, { getAcquisitionMethodValue, getReceivingWorkflowValue, @@ -55,6 +56,47 @@ describe('POLineDetails', () => { expect(screen.getByText('ui-orders.poLine.poLineDescription')).toBeInTheDocument(); }); + describe('Automatic export hint', () => { + const { ediConfig } = integrationConfig.exportTypeSpecificParameters.vendorEdiOrdersExportConfig; + const line = { + automaticExport: true, + acquisitionMethod: ediConfig.defaultAcquisitionMethods[0], + vendorDetail: { vendorAccount: ediConfig.accountNoList[0] }, + }; + + it('should show the matching integration', () => { + renderPOLineDetails({ line, integrationConfigs: [integrationConfig] }); + + expect(screen.getByText('ui-orders.poLine.automaticExport.exportVia')).toBeInTheDocument(); + }); + + it('should show a warning when no integration matches', () => { + renderPOLineDetails({ line, integrationConfigs: [] }); + + expect(screen.getByRole('button', { name: 'ui-orders.poLine.automaticExport.noIntegration' })).toBeInTheDocument(); + }); + + it('should not show a warning without integration configs (order template view)', () => { + renderPOLineDetails({ line }); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('should not show a warning while the integration configs are loading', () => { + renderPOLineDetails({ line, integrationConfigs: [], isIntegrationConfigsLoading: true }); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('should show the manual order info instead of the hint for a manual order', () => { + renderPOLineDetails({ line, integrationConfigs: [], manualOrder: true }); + + expect(screen.queryByText('ui-orders.poLine.automaticExport.exportVia')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'ui-orders.poLine.automaticExport.noIntegration' })).not.toBeInTheDocument(); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + }); + describe('getAcquisitionMethodValue', () => { const intl = { formatMessage: ({ id }) => id }; diff --git a/src/components/POLine/POLineDetails/POLineDetailsForm.test.js b/src/components/POLine/POLineDetails/POLineDetailsForm.test.js index 757a5b406..440511813 100644 --- a/src/components/POLine/POLineDetails/POLineDetailsForm.test.js +++ b/src/components/POLine/POLineDetails/POLineDetailsForm.test.js @@ -12,8 +12,20 @@ import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; import { RECEIPT_STATUS } from '@folio/stripes-acq-components'; import { WORKFLOW_STATUS } from '../../../common/constants'; +import { useAcqMethods } from '../../../common/hooks/useAcqMethods'; +import { toggleAutomaticExport } from '../../Utils/toggleAutomaticExport'; import POLineDetailsForm from './POLineDetailsForm'; +jest.mock('../../../common/hooks/useAcqMethods', () => ({ + useAcqMethods: jest.fn(() => ({ acqMethods: [], isLoading: false })), +})); +jest.mock('../../Utils/toggleAutomaticExport', () => ({ + ...jest.requireActual('../../Utils/toggleAutomaticExport'), + toggleAutomaticExport: jest.fn(), +})); + +const acqMethod = { id: 'acq-method-id', value: 'Purchase' }; + const defaultProps = { materialTypes: [{ label: 'label', @@ -296,6 +308,37 @@ describe('POLineDetailsForm', () => { expect(claimingIntervalField).toBeValid(); }); + describe('Acquisition method change', () => { + const selectAcqMethod = async () => { + await userEvent.click(screen.getByRole('button', { name: /ui-orders.poLine.acquisitionMethod/ })); + await userEvent.click(screen.getByRole('option', { name: /acquisition_method/ })); + }; + + beforeEach(() => { + toggleAutomaticExport.mockClear(); + useAcqMethods.mockReturnValue({ acqMethods: [acqMethod], isLoading: false }); + }); + + it('should recalculate automatic export', async () => { + renderPOLineDetailsForm({ integrationConfigs: [] }); + + await selectAcqMethod(); + + expect(toggleAutomaticExport).toHaveBeenCalled(); + }); + + it('should not recalculate automatic export for a manual order', async () => { + renderPOLineDetailsForm({ + integrationConfigs: [], + order: { workflowStatus: 'Pending', manualPo: true }, + }); + + await selectAcqMethod(); + + expect(toggleAutomaticExport).not.toHaveBeenCalled(); + }); + }); + it('should `Bindary Active` field be disabled if "orderFormat" is not `physicalResource` nor `PEMix`', async () => { renderPOLineDetailsForm(null, { orderFormat: 'Electronic' }); diff --git a/src/components/POLine/POLineView.test.js b/src/components/POLine/POLineView.test.js index 90f113014..ab871047f 100644 --- a/src/components/POLine/POLineView.test.js +++ b/src/components/POLine/POLineView.test.js @@ -3,7 +3,10 @@ import { MemoryRouter } from 'react-router-dom'; import user from '@folio/jest-config-stripes/testing-library/user-event'; import { act, render, screen } from '@folio/jest-config-stripes/testing-library/react'; -import { ORDER_TYPES } from '@folio/stripes-acq-components'; +import { + ORDER_TYPES, + useIntegrationConfigs, +} from '@folio/stripes-acq-components'; import { Pluggable } from '@folio/stripes/core'; import { @@ -41,6 +44,7 @@ jest.mock('@folio/stripes-acq-components', () => ({ DonorsListContainer: jest.fn().mockReturnValue('DonorsListContainer'), FundDistributionView: jest.fn(() => 'FundDistributionView'), RoutingListAccordion: jest.fn().mockReturnValue('RoutingListAccordion'), + useIntegrationConfigs: jest.fn(() => ({ integrationConfigs: [], isFetching: false })), useOrganization: jest.fn(() => ({ organization: undefined })), })); jest.mock('@folio/stripes/smart-components', () => ({ @@ -171,6 +175,33 @@ describe('POLineView', () => { })); }); + describe('Integration configs for the export hint', () => { + beforeEach(() => { + useIntegrationConfigs.mockClear(); + }); + + it('should fetch them when automatic export is on', () => { + renderPOLineView({ line: { ...orderLine, automaticExport: true } }); + + expect(useIntegrationConfigs).toHaveBeenCalledWith({ organizationId: order.vendor }); + }); + + it('should not fetch them when automatic export is off', () => { + renderPOLineView({ line: { ...orderLine, automaticExport: false } }); + + expect(useIntegrationConfigs).toHaveBeenCalledWith({ organizationId: undefined }); + }); + + it('should not fetch them for a manual order', () => { + renderPOLineView({ + line: { ...orderLine, automaticExport: true }, + order: { ...order, manualPo: true }, + }); + + expect(useIntegrationConfigs).toHaveBeenCalledWith({ organizationId: undefined }); + }); + }); + it('should render Ongoing order information accordion', async () => { renderPOLineView({ ...defaultProps, order: { ...order, orderType: ORDER_TYPES.ongoing } }); diff --git a/src/components/POLine/Vendor/VendorForm.test.js b/src/components/POLine/Vendor/VendorForm.test.js index 535017eaf..42de54c28 100644 --- a/src/components/POLine/Vendor/VendorForm.test.js +++ b/src/components/POLine/Vendor/VendorForm.test.js @@ -1,10 +1,17 @@ import { Form } from 'react-final-form'; import { render, screen } from '@folio/jest-config-stripes/testing-library/react'; +import userEvent from '@folio/jest-config-stripes/testing-library/user-event'; import { arrayMutators } from 'fixtures/arrayMutatorsMock'; +import { toggleAutomaticExport } from '../../Utils/toggleAutomaticExport'; import VendorForm from './VendorForm'; +jest.mock('../../Utils/toggleAutomaticExport', () => ({ + ...jest.requireActual('../../Utils/toggleAutomaticExport'), + toggleAutomaticExport: jest.fn(), +})); + const defaultProps = { accounts: [{ name: 'name', @@ -63,4 +70,36 @@ describe('VendorForm', () => { expect(screen.getByText(`${accounts[0].name} (${accounts[0].accountNo})`)).toBeInTheDocument(); expect(screen.getByText(`${accounts[2].name} (${accounts[2].accountNo})`)).toBeInTheDocument(); }); + + describe('Account number change', () => { + const accounts = [{ + name: 'name', + accountNo: 'accountNo', + accountStatus: 'Active', + }]; + + beforeEach(() => { + toggleAutomaticExport.mockClear(); + }); + + it('should recalculate automatic export', async () => { + renderVendorForm({ accounts, integrationConfigs: [] }); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'accountNo'); + + expect(toggleAutomaticExport).toHaveBeenCalled(); + }); + + it('should not recalculate automatic export for a manual order', async () => { + renderVendorForm({ + accounts, + integrationConfigs: [], + order: { workflowStatus: 'Pending', manualPo: true }, + }); + + await userEvent.selectOptions(screen.getByRole('combobox'), 'accountNo'); + + expect(toggleAutomaticExport).not.toHaveBeenCalled(); + }); + }); }); From 48e63166be41fdf4c4970eea2428c04e64232346 Mon Sep 17 00:00:00 2001 From: schefter Date: Mon, 26 Oct 2026 09:31:40 +0100 Subject: [PATCH 4/5] UIOR-1556 Ignore accounts left on default configs When an integration with account numbers is switched to the default integration, ui-organizations hides the account field but keeps the stored list. The default integration then excluded its own PO lines in the matching, and the hint reported no matching integration although the default one applies. Build the exclusion list of the default integration from the non-default integrations only, as mod-data-export-worker does since MODEXPW-664. Pin the rule for a vendor whose integrations have no account numbers at all (MODEXPW-663) with a test as well. --- src/components/Utils/toggleAutomaticExport.js | 8 +++- .../Utils/toggleAutomaticExport.test.js | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/components/Utils/toggleAutomaticExport.js b/src/components/Utils/toggleAutomaticExport.js index 423a50eb6..43f290d07 100644 --- a/src/components/Utils/toggleAutomaticExport.js +++ b/src/components/Utils/toggleAutomaticExport.js @@ -22,7 +22,8 @@ const isAcqMethodIncluded = (orderingConfig, acquisitionMethod) => ( * - non-default, accountNoList not empty: vendorAccount is in the list * - non-default, accountNoList empty: every line * - default, vendor has several ordering configs: vendorAccount is in none - * of their account lists + * of the non-default configs' account lists (a default config may still + * store a hidden list, MODEXPW-664); no such accounts at all: every line * - default, single ordering config: every line * Without an acquisition method the backend job fails, so nothing applies. * @@ -38,7 +39,10 @@ export const getApplicableOrderingIntegrations = ({ vendorAccount, acquisitionMe const hasMultipleConfigs = orderingConfigs.length > 1; const accountAntiList = hasMultipleConfigs - ? orderingConfigs.flatMap(config => getOrderingConfig(config)?.ediConfig?.accountNoList ?? []) + ? orderingConfigs + .map(getOrderingConfig) + .filter(orderingConfig => !orderingConfig.isDefaultConfig) + .flatMap(orderingConfig => orderingConfig.ediConfig?.accountNoList ?? []) : []; return orderingConfigs.filter((config) => { diff --git a/src/components/Utils/toggleAutomaticExport.test.js b/src/components/Utils/toggleAutomaticExport.test.js index d6cbecf9f..1181c9378 100644 --- a/src/components/Utils/toggleAutomaticExport.test.js +++ b/src/components/Utils/toggleAutomaticExport.test.js @@ -138,4 +138,51 @@ describe('getApplicableOrderingIntegrations', () => { expect(applicable).toEqual([defaultOrdering]); }); + + it('should let the default integration apply when no integration has account numbers', () => { + const defaultOrdering = orderingConfig({ + isDefaultConfig: true, + ediConfig: { + defaultAcquisitionMethods: [acquisitionMethod], + }, + }); + const emailOrdering = orderingConfig({ + ediConfig: { + defaultAcquisitionMethods: ['other-acq-method'], + accountNoList: [], + }, + }); + + const applicable = getApplicableOrderingIntegrations({ + vendorAccount, + acquisitionMethod, + integrationConfigs: [defaultOrdering, emailOrdering], + }); + + expect(applicable).toEqual([defaultOrdering]); + }); + + it('should ignore account numbers left on the default integration', () => { + const defaultOrdering = orderingConfig({ + isDefaultConfig: true, + ediConfig: { + defaultAcquisitionMethods: [acquisitionMethod], + accountNoList: [vendorAccount], + }, + }); + const otherOrdering = orderingConfig({ + ediConfig: { + defaultAcquisitionMethods: ['other-acq-method'], + accountNoList: ['other-account'], + }, + }); + + const applicable = getApplicableOrderingIntegrations({ + vendorAccount, + acquisitionMethod, + integrationConfigs: [defaultOrdering, otherOrdering], + }); + + expect(applicable).toEqual([defaultOrdering]); + }); }); From bbee4ef9288ff94db93940f5401ac48641a76c42 Mon Sep 17 00:00:00 2001 From: schefter Date: Wed, 4 Nov 2026 14:21:09 +0100 Subject: [PATCH 5/5] UIOR-1556 Hide export hint without config access Reading the integration configs needs data-export.config.collection.get, which ui-orders grants with "Orders: Can create" and "Orders: Can edit" but not with "Orders: Can view". For a user who can only view orders the request failed with 403, the hook returned an empty list, and the detail view showed "No matching integration configured" although integrations exist. Load the configs and show the hint only with that permission. Granting it to viewers is not an option: the configs response contains the FTP credentials of FTP integrations. --- src/components/POLine/POLineView.js | 7 +++++-- src/components/POLine/POLineView.test.js | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/components/POLine/POLineView.js b/src/components/POLine/POLineView.js index 35aeb5e49..0fa0fabb3 100644 --- a/src/components/POLine/POLineView.js +++ b/src/components/POLine/POLineView.js @@ -187,12 +187,15 @@ const POLineView = ({ const isCancelable = isCancelableLine(line, order); + // Without the permission the request fails and the hint would claim that no integration matches. + const canViewIntegrations = stripes.hasPerm('data-export.config.collection.get'); + // Fetched only when the export hint can show something. const { integrationConfigs, isFetching: isIntegrationConfigsFetching, } = useIntegrationConfigs({ - organizationId: (line.automaticExport && !order?.manualPo) ? order?.vendor : undefined, + organizationId: (canViewIntegrations && line.automaticExport && !order?.manualPo) ? order?.vendor : undefined, }); const { hasLocationRestrictedFund } = useIsFundsRestrictedByLocationIds(line); @@ -448,7 +451,7 @@ const POLineView = ({ diff --git a/src/components/POLine/POLineView.test.js b/src/components/POLine/POLineView.test.js index ab871047f..d588bd7ee 100644 --- a/src/components/POLine/POLineView.test.js +++ b/src/components/POLine/POLineView.test.js @@ -7,7 +7,10 @@ import { ORDER_TYPES, useIntegrationConfigs, } from '@folio/stripes-acq-components'; -import { Pluggable } from '@folio/stripes/core'; +import { + Pluggable, + useStripes, +} from '@folio/stripes/core'; import { orderLine, @@ -180,6 +183,10 @@ describe('POLineView', () => { useIntegrationConfigs.mockClear(); }); + afterEach(() => { + useStripes().hasPerm.mockImplementation(() => true); + }); + it('should fetch them when automatic export is on', () => { renderPOLineView({ line: { ...orderLine, automaticExport: true } }); @@ -200,6 +207,15 @@ describe('POLineView', () => { expect(useIntegrationConfigs).toHaveBeenCalledWith({ organizationId: undefined }); }); + + it('should neither fetch them nor show a warning without the permission to read integrations', () => { + useStripes().hasPerm.mockImplementation((perm) => perm !== 'data-export.config.collection.get'); + + renderPOLineView({ line: { ...orderLine, automaticExport: true } }); + + expect(useIntegrationConfigs).toHaveBeenCalledWith({ organizationId: undefined }); + expect(screen.queryByRole('button', { name: 'ui-orders.poLine.automaticExport.noIntegration' })).not.toBeInTheDocument(); + }); }); it('should render Ongoing order information accordion', async () => {