diff --git a/CHANGELOG.md b/CHANGELOG.md index 98025c4..ea0296f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## (IN PROGRESS) +* Support EDIFACT claims export. Refs UIEXPMGR-113. +* Support CSV claims export. Refs UIEXPMGR-114. + ## [3.2.0](https://github.com/folio-org/ui-export-manager/tree/v3.2.0) (2024-10-31) [Full Changelog](https://github.com/folio-org/ui-export-manager/compare/v3.1.1...v3.2.0) diff --git a/src/ExportEdiJobs/ExportEdiJobDetails/ExportEdiJobDetails.js b/src/ExportEdiJobs/ExportEdiJobDetails/ExportEdiJobDetails.js index eb113c6..a857100 100644 --- a/src/ExportEdiJobs/ExportEdiJobDetails/ExportEdiJobDetails.js +++ b/src/ExportEdiJobs/ExportEdiJobDetails/ExportEdiJobDetails.js @@ -1,6 +1,9 @@ -import React, { useCallback } from 'react'; +import { useCallback } from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { + FormattedMessage, + useIntl, +} from 'react-intl'; import { Col, @@ -26,14 +29,24 @@ import { useNavigation } from '../../hooks'; import { useExportJobQuery } from '../../ExportJob/apiQuery'; import { ExportEdiJobDetailsActionMenu } from '../ExportEdiJobDetailsActionMenu'; +const FILE_DOWNLOAD = 'File download'; + +const getSentToValue = (exportConfig, intl) => { + if (exportConfig?.transmissionMethod === FILE_DOWNLOAD) { + return intl.formatMessage({ id: 'ui-export-manager.exportJob.download' }); + } + + return `${exportConfig?.ediFtp?.serverAddress}${exportConfig?.ediFtp?.orderDirectory || ''}`; +}; + export const ExportEdiJobDetails = ({ refetchJobs, uuid }) => { - const { formatMessage } = useIntl(); + const intl = useIntl(); const { navigateToEdiJobs } = useNavigation(); - const perms = useExportManagerPerms() + const perms = useExportManagerPerms(); const { hasAllExportManagerPerms - } = perms + } = perms; const { isLoading: isJobLoading, @@ -54,7 +67,7 @@ export const ExportEdiJobDetails = ({ refetchJobs, uuid }) => { isLoading: isOrganizationLoading, } = useOrganization(exportConfig?.vendorId); - const title = formatMessage( + const title = intl.formatMessage( { id: 'ui-export-manager.exportJob' }, { jobId: exportJob.jobId }, ); @@ -164,7 +177,7 @@ export const ExportEdiJobDetails = ({ refetchJobs, uuid }) => { } - value={`${exportConfig?.ediFtp?.serverAddress}${exportConfig?.ediFtp?.orderDirectory || ''}`} + value={getSentToValue(exportConfig, intl)} /> diff --git a/src/ExportEdiJobs/ExportEdiJobsFilters/ExportEdiJobsFilters.js b/src/ExportEdiJobs/ExportEdiJobsFilters/ExportEdiJobsFilters.js index 84bc344..7afe1c0 100644 --- a/src/ExportEdiJobs/ExportEdiJobsFilters/ExportEdiJobsFilters.js +++ b/src/ExportEdiJobs/ExportEdiJobsFilters/ExportEdiJobsFilters.js @@ -15,15 +15,18 @@ import { PluggableOrganizationFilter, } from '@folio/stripes-acq-components'; -import { EXPORT_JOB_STATUS_OPTIONS } from '../../common/constants'; +import { + EXPORT_JOB_STATUS_OPTIONS, + ORGANIZATION_INTEGRATION_TYPE_OPTIONS, +} from '../../common/constants'; import { ExportMethodFilter } from './ExportMethodFilter'; const applyFiltersAdapter = (applyFilters) => ({ name, values }) => applyFilters(name, values); export const ExportEdiJobsFilters = ({ - disabled = false, - activeFilters, - applyFilters, + disabled = false, + activeFilters, + applyFilters, }) => { const adaptedApplyFilters = useCallback( applyFiltersAdapter(applyFilters), @@ -43,6 +46,17 @@ export const ExportEdiJobsFilters = ({ closedByDefault={false} /> + + { + const configName = ORGANIZATION_INTEGRATION_EXPORT_TYPES + .map(type => `"${type}_${organizationId}*"`) + .join(JOIN_STRING); + + return `configName==(${configName})`; +}; + +const buildTypeCql = () => { + const type = ORGANIZATION_INTEGRATION_EXPORT_TYPES + .map((t) => `"${t}"`) + .join(JOIN_STRING); + + return `type==(${type})`; +}; export const useConfigs = (organizationId) => { const ky = useOkapiKy(); @@ -14,14 +35,14 @@ export const useConfigs = (organizationId) => { const searchParams = { query: organizationId - ? `configName==EDIFACT_ORDERS_EXPORT_${organizationId}*` - : 'type==EDIFACT_ORDERS_EXPORT', + ? buildConfigNameCql(organizationId) + : buildTypeCql(), limit: LIMIT_MAX, }; const { isFetching, data = {} } = useQuery( [namespace, organizationId], - () => ky.get('data-export-spring/configs', { searchParams }).json(), + () => ky.get(DATA_EXPORT_CONFIGS_API, { searchParams }).json(), ); return ({ diff --git a/src/ExportEdiJobs/ExportEdiJobsFilters/ExportMethodFilter/useConfigs.test.js b/src/ExportEdiJobs/ExportEdiJobsFilters/ExportMethodFilter/useConfigs.test.js index 3374844..08f3130 100644 --- a/src/ExportEdiJobs/ExportEdiJobsFilters/ExportMethodFilter/useConfigs.test.js +++ b/src/ExportEdiJobs/ExportEdiJobsFilters/ExportMethodFilter/useConfigs.test.js @@ -47,7 +47,7 @@ describe('useConfigs', () => { 'data-export-spring/configs', { searchParams: { - query: 'type==EDIFACT_ORDERS_EXPORT', + query: 'type==("CLAIMS" or "EDIFACT_ORDERS_EXPORT")', limit: LIMIT_MAX, }, }, diff --git a/src/ExportEdiJobs/apiQuery.js b/src/ExportEdiJobs/apiQuery.js index 79f971f..5c2fb14 100644 --- a/src/ExportEdiJobs/apiQuery.js +++ b/src/ExportEdiJobs/apiQuery.js @@ -1,14 +1,29 @@ -import { useInfiniteQuery } from 'react-query'; import queryString from 'query-string'; +import { useInfiniteQuery } from 'react-query'; -import { useOkapiKy, useNamespace } from '@folio/stripes/core'; +import { + useOkapiKy, + useNamespace, +} from '@folio/stripes/core'; import { buildDateRangeQuery, + CQL_OR_OPERATOR, makeQueryBuilder, + ORGANIZATION_INTEGRATION_EXPORT_TYPES, } from '@folio/stripes-acq-components'; +const buildIntegrationTypesCqlValue = (type) => { + if (!Array.isArray(type)) return type; + + const value = type + .map((t) => `"${t}"`) + .join(` ${CQL_OR_OPERATOR} `); + + return `(${value})`; +}; + const buildJobsQuery = makeQueryBuilder( - 'type="EDIFACT_ORDERS_EXPORT"', + `type=${buildIntegrationTypesCqlValue(ORGANIZATION_INTEGRATION_EXPORT_TYPES)}`, (query) => { return `name="*${query}*" or description="*${query}*"`; }, @@ -18,6 +33,9 @@ const buildJobsQuery = makeQueryBuilder( startTime: buildDateRangeQuery.bind(null, ['startTime']), vendorId: (id) => `jsonb.exportTypeSpecificParameters.vendorEdiOrdersExportConfig.vendorId=="${id}"`, exportConfigId: (id) => `jsonb.exportTypeSpecificParameters.vendorEdiOrdersExportConfig.exportConfigId=="${id}"`, + integrationType: (type) => ( + `jsonb.exportTypeSpecificParameters.vendorEdiOrdersExportConfig.integrationType==${buildIntegrationTypesCqlValue(type)}` + ), }, { jobId: 'name', @@ -41,7 +59,10 @@ export const useExportEdiJobsQuery = (search, pagination, filters) => { searchParams: { limit: pagination.limit, offset: pagination.offset, - query: buildJobsQuery(queryString.parse(`${search}&type=EDIFACT_ORDERS_EXPORT`)), + query: buildJobsQuery({ + type: ORGANIZATION_INTEGRATION_EXPORT_TYPES, + ...queryString.parse(`${search}`), + }), }, }; diff --git a/src/ExportJobs/ExportJobsFilters/ExportJobsFilters.js b/src/ExportJobs/ExportJobsFilters/ExportJobsFilters.js index ee67771..ce5ebf9 100644 --- a/src/ExportJobs/ExportJobsFilters/ExportJobsFilters.js +++ b/src/ExportJobs/ExportJobsFilters/ExportJobsFilters.js @@ -49,9 +49,9 @@ const typeFilterOptions = EXPORT_JOB_TYPES.map(type => ({ })); export const ExportJobsFilters = ({ - disabled = false, - activeFilters, - applyFilters, + disabled = false, + activeFilters, + applyFilters, }) => { const adaptedApplyFilters = useCallback( applyFiltersAdapter(applyFilters), diff --git a/src/ExportJobs/apiQuery.js b/src/ExportJobs/apiQuery.js index 0d33744..7ee2d65 100644 --- a/src/ExportJobs/apiQuery.js +++ b/src/ExportJobs/apiQuery.js @@ -1,36 +1,94 @@ -import { useInfiniteQuery } from 'react-query'; import queryString from 'query-string'; +import { useInfiniteQuery } from 'react-query'; import { useOkapiKy } from '@folio/stripes/core'; import { buildDateRangeQuery, + CQL_AND_OPERATOR, + CQL_OR_OPERATOR, makeQueryBuilder, + ORGANIZATION_INTEGRATION_EXPORT_TYPES, } from '@folio/stripes-acq-components'; -const BULK_EDIT_QUERY = 'type==("BULK_EDIT_IDENTIFIERS" or "BULK_EDIT_QUERY" or "BULK_EDIT_UPDATE")'; +import { EXPORT_FILE_TYPE } from '../common/constants'; +import { EXPORT_JOB_TYPE_KEYS } from './constants'; + +const AND_SEPARATOR = ` ${CQL_AND_OPERATOR} `; +const OR_SEPARATOR = ` ${CQL_OR_OPERATOR} `; const BULK_EDIT_TYPE = '"BULK_EDIT_IDENTIFIERS" or "BULK_EDIT_QUERY" or "BULK_EDIT_UPDATE"'; -const BULK_EDIT = 'BULK_EDIT'; +const EDI_ORDERS_FILE_FORMAT_KEY = 'jsonb.exportTypeSpecificParameters.vendorEdiOrdersExportConfig.fileFormat'; + +const ORDERS_JOB_TYPES_CQL_VALUE = ORGANIZATION_INTEGRATION_EXPORT_TYPES + .map((t) => `"${t}"`) + .join(` ${CQL_OR_OPERATOR} `); + +const buildOrdersJobTypeQueryDict = (fileType) => ({ + type: `${ORDERS_JOB_TYPES_CQL_VALUE}`, + [EDI_ORDERS_FILE_FORMAT_KEY]: `"${fileType}"`, +}); + +const typeQueryDict = { + [EXPORT_JOB_TYPE_KEYS.BULK_EDIT]: { type: `${BULK_EDIT_TYPE}` }, + [EXPORT_JOB_TYPE_KEYS.ORDERS_CSV]: buildOrdersJobTypeQueryDict(EXPORT_FILE_TYPE.csv), + [EXPORT_JOB_TYPE_KEYS.ORDERS_EDI]: buildOrdersJobTypeQueryDict(EXPORT_FILE_TYPE.edi), +}; + +/* + * Function to build CQL query from an array of dictionaries (objects) + */ +const buildCqlQueryFromDicts = (arr) => { + /* Group objects by their keys to optimize the CQL query */ + const grouped = arr.reduce((acc, obj) => { + const key = Object.keys(obj) + .sort((a, b) => a.localeCompare(b)) + .join('_'); + + acc[key] = acc[key] || []; + acc[key].push(obj); + + return acc; + }, {}); + + /* Transform grouped objects into CQL conditions */ + const cqlParts = Object.values(grouped).map((group) => { + const uniqueConditions = group.reduce((acc, obj) => { + Object.entries(obj).forEach(([key, value]) => { + if (!acc[key]) { + acc[key] = new Set(); + } + acc[key].add(value); + }); + + return acc; + }, {}); + + const conditions = Object.entries(uniqueConditions) + .map(([key, values]) => `${key}==(${[...values].join(OR_SEPARATOR)})`) + .join(AND_SEPARATOR); + + return `(${conditions})`; + }); + + return cqlParts.join(OR_SEPARATOR); +}; + +/* + * Function to map job types to their CQL representation +*/ +const mapJobTypesToCql = (types) => { + const queryDicts = types.map((type) => typeQueryDict[type] ?? { type }); + + return buildCqlQueryFromDicts(queryDicts); +}; const buildJobsQuery = makeQueryBuilder( 'cql.allRecords=1', - (query) => { - return `name="*${query}*" or description="*${query}*"`; - }, + (query) => `name="*${query}*" or description="*${query}*"`, 'sortby name/sort.descending', { endTime: buildDateRangeQuery.bind(null, ['endTime']), startTime: buildDateRangeQuery.bind(null, ['startTime']), - type: (query) => { - if (query === BULK_EDIT) { - return BULK_EDIT_QUERY; - } else if (Array.isArray(query)) { - return `type==(${query.map(v => { - if (v === BULK_EDIT) { - return BULK_EDIT_TYPE; - } else return `"${v}"`; - }).join(' or ')})`; - } else return `type==${query}`; - }, + type: (query) => mapJobTypesToCql(Array.isArray(query) ? query : [query]), }, { jobId: 'name', diff --git a/src/ExportJobs/apiQuery.test.js b/src/ExportJobs/apiQuery.test.js index c71089d..562c80b 100644 --- a/src/ExportJobs/apiQuery.test.js +++ b/src/ExportJobs/apiQuery.test.js @@ -1,90 +1,111 @@ -import React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; import { renderHook } from '@testing-library/react-hooks'; +import { + QueryClient, + QueryClientProvider, +} from 'react-query'; -import '@folio/stripes-acq-components/test/jest/__mock__'; import { useOkapiKy } from '@folio/stripes/core'; -import { - useExportJobsQuery, -} from './apiQuery'; +import { useExportJobsQuery } from './apiQuery'; const queryClient = new QueryClient(); - -// eslint-disable-next-line react/prop-types const wrapper = ({ children }) => ( {children} ); -describe('Export jobs api queries', () => { - describe('useExportJobsQuery', () => { - it('should fetch export jobs', async () => { - const exportJobs = [{ - id: 'uuias43', - name: '123', - description: '# of Charges: 5', - }]; - - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - jobRecords: exportJobs, - totalRecords: 1, - }), +describe('useExportJobsQuery', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch export jobs and map them correctly', async () => { + const exportJobs = [{ + id: 'uuias43', + name: '123', + description: '# of Charges: 5', + }]; + const totalRecords = 1; + + useOkapiKy.mockReturnValue({ + get: jest.fn().mockReturnValue({ + json: () => ({ + jobRecords: exportJobs, + totalRecords, }), - }); - - const { result, waitFor } = renderHook(() => useExportJobsQuery( - '?limit=100&offset=0&status=SCHEDULED', { - offset: 30, - limit: 30, - }, - { - status: 'SCHEDULED', - }, - ), { wrapper }); - - await waitFor(() => { - return !result.current.isLoading; - }); - - expect(result.current.exportJobs).toEqual(exportJobs); + }), }); - it('should return total records count', async () => { - const exportJobs = [{ - id: 'uuias43', - name: '123', - description: '# of Charges: 5', - }]; - const totalRecords = 1; - - useOkapiKy.mockClear().mockReturnValue({ - get: () => ({ - json: () => ({ - jobRecords: exportJobs, - totalRecords, - }), + const { result, waitFor } = renderHook(() => useExportJobsQuery( + '?limit=100&offset=0&status=SCHEDULED&type=BULK_EDIT', { + offset: 30, + limit: 30, + }, + { + status: 'SCHEDULED', + type: 'BULK_EDIT', + }, + ), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBeFalsy()); + + expect(result.current.exportJobs).toEqual(exportJobs); + expect(result.current.totalCount).toEqual(totalRecords); + }); + + it('should handle empty result gracefully', async () => { + useOkapiKy.mockReturnValue({ + get: jest.fn().mockReturnValue({ + json: () => ({ + jobRecords: [], + totalRecords: 0, }), - }); - - const { result, waitFor } = renderHook(() => useExportJobsQuery( - '?limit=100&offset=0&status=SCHEDULED', { - offset: 30, - limit: 30, - }, - { - status: 'SCHEDULED', - }, - ), { wrapper }); - - await waitFor(() => { - return !result.current.isLoading; - }); - - expect(result.current.totalCount).toEqual(totalRecords); + }), }); + + const { result, waitFor } = renderHook(() => useExportJobsQuery( + '?limit=100&offset=0', { + offset: 0, + limit: 10, + }, + {}, + ), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBeFalsy()); + + expect(result.current.exportJobs).toEqual([]); + }); + + it('should correctly call the API with generated CQL query', async () => { + const getMock = jest.fn().mockReturnValue({ + json: () => ({ + jobRecords: [], + totalRecords: 0, + }), + }); + + useOkapiKy.mockReturnValue({ get: getMock }); + + const { result, waitFor } = renderHook(() => useExportJobsQuery( + '?limit=100&offset=0&status=SCHEDULED', { + offset: 30, + limit: 30, + }, + { + status: 'SCHEDULED', + }, + ), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBeFalsy()); + + expect(getMock).toHaveBeenCalledWith( + 'data-export-spring/jobs', + expect.objectContaining({ + searchParams: expect.objectContaining({ + query: expect.stringContaining('status=="SCHEDULED"'), + }), + }), + ); }); }); diff --git a/src/ExportJobs/constants.js b/src/ExportJobs/constants.js index 9b9b187..2aab5e9 100644 --- a/src/ExportJobs/constants.js +++ b/src/ExportJobs/constants.js @@ -19,7 +19,8 @@ export const EXPORT_JOB_TYPE_KEYS = { CIRCULATION_LOG: 'CIRCULATION_LOG', E_HOLDINGS: 'E_HOLDINGS', BULK_EDIT: 'BULK_EDIT', - EDIFACT_ORDERS_EXPORT: 'EDIFACT_ORDERS_EXPORT', + ORDERS_EDI: 'ORDERS_EDI', + ORDERS_CSV: 'ORDERS_CSV', }; export const EXPORT_JOB_TYPES = [ @@ -28,7 +29,8 @@ export const EXPORT_JOB_TYPES = [ EXPORT_JOB_TYPE_KEYS.CIRCULATION_LOG, EXPORT_JOB_TYPE_KEYS.E_HOLDINGS, EXPORT_JOB_TYPE_KEYS.BULK_EDIT, - EXPORT_JOB_TYPE_KEYS.EDIFACT_ORDERS_EXPORT, + EXPORT_JOB_TYPE_KEYS.ORDERS_EDI, + EXPORT_JOB_TYPE_KEYS.ORDERS_CSV, ]; export const EXPORT_JOB_TYPES_REQUEST_MAP = { diff --git a/src/common/constants.js b/src/common/constants.js index b988b59..9696fbc 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -13,6 +13,16 @@ export const EXPORT_JOB_STATUS_OPTIONS = Object.values(EXPORT_JOB_STATUSES).map( label: , })); +export const ORGANIZATION_INTEGRATION_TYPE = { + claiming: 'Claiming', + ordering: 'Ordering', +}; + +export const ORGANIZATION_INTEGRATION_TYPE_OPTIONS = Object.values(ORGANIZATION_INTEGRATION_TYPE).map((type) => ({ + value: type, + label: , +})); + export const EXPORT_CONFIGS_API = 'data-export-spring/configs'; export const EXPORT_JOBS_API = 'data-export-spring/jobs'; export const MULTIPLE_EXPORTED_JOB_TYPES = [ @@ -28,3 +38,8 @@ export const EXPORTED_JOB_TYPES = [ 'AUTH_HEADINGS_UPDATES', 'FAILED_LINKED_BIB_UPDATES', ]; + +export const EXPORT_FILE_TYPE = { + edi: 'EDI', + csv: 'CSV', +}; diff --git a/translations/ui-export-manager/en.json b/translations/ui-export-manager/en.json index be09e70..47c6989 100644 --- a/translations/ui-export-manager/en.json +++ b/translations/ui-export-manager/en.json @@ -7,6 +7,7 @@ "exportJobs.search": "Search", "exportJob": "Export job {jobId}", + "exportJob.download": "Download", "exportJob.jobId": "Job ID", "exportJob.status": "Status", "exportJob.type": "Job type", @@ -22,6 +23,10 @@ "exportJob.sentTo": "Sent to", "exportJob.fileName": "File name", + "exportJob.integrationType": "Integration type", + "exportJob.integrationType.claiming": "Claims", + "exportJob.integrationType.ordering": "Orders", + "exportJob.status.SCHEDULED": "Scheduled", "exportJob.status.IN_PROGRESS": "In progress", "exportJob.status.SUCCESSFUL": "Successful", @@ -29,12 +34,13 @@ "exportJob.type.CIRCULATION_LOG": "Circulation log", "exportJob.type.BURSAR_FEES_FINES": "Bursar", - "exportJob.type.EDIFACT_ORDERS_EXPORT": "EDIFACT orders export", "exportJob.type.E_HOLDINGS": "eHoldings", "exportJob.type.BULK_EDIT": "Bulk edit", "exportJob.type.AUTH_HEADINGS_UPDATES": "Authority control", "exportJob.type.BULK_EDIT_IDENTIFIERS": "Bulk edit identifiers", "exportJob.type.BULK_EDIT_QUERY": "Bulk edit query", + "exportJob.type.ORDERS_EDI": "Orders (EDI)", + "exportJob.type.ORDERS_CSV": "Orders (CSV)", "exportJob.details.type.CIRCULATION_LOG": "Circulation log", "exportJob.details.type.BURSAR_FEES_FINES": "Bursar",