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",