diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c7ff40d7..08a8ba4d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@
* Add Order email templates settings with token picker and Handlebars backend-rendered preview. Refs UIOR-1492, UIOR-1493, UIOR-1495.
* Do not show metadata fields in "Version history" for PO and PO Lines. Refs UIOR-1585.
* Preserve the "Create inventory" and "Receiving workflow" fields values according to the form value precedence rules. Refs UIOR-1582.
+* 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/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.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/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/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/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.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/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.js b/src/components/POLine/POLineView.js
index 4a68c6804..0fa0fabb3 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,17 @@ 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: (canViewIntegrations && line.automaticExport && !order?.manualPo) ? order?.vendor : undefined,
+ });
+
const { hasLocationRestrictedFund } = useIsFundsRestrictedByLocationIds(line);
useEffect(() => {
@@ -439,6 +451,9 @@ const POLineView = ({
{isOngoing(order.orderType) && (
diff --git a/src/components/POLine/POLineView.test.js b/src/components/POLine/POLineView.test.js
index 90f113014..d588bd7ee 100644
--- a/src/components/POLine/POLineView.test.js
+++ b/src/components/POLine/POLineView.test.js
@@ -3,8 +3,14 @@ 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 { Pluggable } from '@folio/stripes/core';
+import {
+ ORDER_TYPES,
+ useIntegrationConfigs,
+} from '@folio/stripes-acq-components';
+import {
+ Pluggable,
+ useStripes,
+} from '@folio/stripes/core';
import {
orderLine,
@@ -41,6 +47,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 +178,46 @@ describe('POLineView', () => {
}));
});
+ describe('Integration configs for the export hint', () => {
+ beforeEach(() => {
+ useIntegrationConfigs.mockClear();
+ });
+
+ afterEach(() => {
+ useStripes().hasPerm.mockImplementation(() => true);
+ });
+
+ 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 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 () => {
renderPOLineView({ ...defaultProps, order: { ...order, orderType: ORDER_TYPES.ongoing } });
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/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();
+ });
+ });
});
diff --git a/src/components/Utils/toggleAutomaticExport.js b/src/components/Utils/toggleAutomaticExport.js
index d68067ad8..43f290d07 100644
--- a/src/components/Utils/toggleAutomaticExport.js
+++ b/src/components/Utils/toggleAutomaticExport.js
@@ -1,38 +1,71 @@
-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 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.
+ *
+ * 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
+ .map(getOrderingConfig)
+ .filter(orderingConfig => !orderingConfig.isDefaultConfig)
+ .flatMap(orderingConfig => orderingConfig.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..1181c9378 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,121 @@ 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]);
+ });
+
+ 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]);
+ });
+});
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',
diff --git a/translations/ui-orders/en.json b/translations/ui-orders/en.json
index 529092f6b..bffb10493 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",