diff --git a/cypress/support/form/helpers/nameToDataTest.js b/cypress/support/form/helpers/nameToDataTest.js index 2dc649353..0c280535b 100644 --- a/cypress/support/form/helpers/nameToDataTest.js +++ b/cypress/support/form/helpers/nameToDataTest.js @@ -21,6 +21,21 @@ export const nameToDataTest = (name) => { case 'idScheme': return 'input-id-scheme' + case 'categoryIdScheme': + return 'input-category-id-scheme' + + case 'categoryOptionIdScheme': + return 'input-category-option-id-scheme' + + case 'categoryOptionComboIdScheme': + return 'input-category-option-combo-id-scheme' + + case 'dataSetIdScheme': + return 'input-data-set-id-scheme' + + case 'attributeOptionComboIdScheme': + return 'input-attribute-option-combo-id-scheme' + case 'program': return 'input-program-picker' diff --git a/i18n/en.pot b/i18n/en.pot index a80af4321..956367327 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-02T14:17:13.038Z\n" -"PO-Revision-Date: 2026-09-02T14:17:13.038Z\n" +"POT-Creation-Date: 2026-09-17T11:35:01.753Z\n" +"PO-Revision-Date: 2026-09-17T11:35:01.754Z\n" msgid "Something went wrong when loading the current user!" msgstr "Something went wrong when loading the current user!" @@ -29,18 +29,23 @@ msgstr "Invalid duration" msgid "Format" msgstr "Format" -msgid "Something went wrong when loading the additional data element ID schemes" -msgstr "Something went wrong when loading the additional data element ID schemes" +msgid "Uid" +msgstr "Uid" + +msgid "Code" +msgstr "Code" + +msgid "Name" +msgstr "Name" msgid "Something went wrong when loading the additional ID schemes" msgstr "Something went wrong when loading the additional ID schemes" -msgid "" -"Something went wrong when loading the additional organisation unit ID " -"schemes" -msgstr "" -"Something went wrong when loading the additional organisation unit ID " -"schemes" +msgid "(Default)" +msgstr "(Default)" + +msgid "(Undefined)" +msgstr "(Undefined)" msgid "Please select a file" msgstr "Please select a file" @@ -139,15 +144,6 @@ msgstr "Uncompressed" msgid "Compression mode" msgstr "Compression mode" -msgid "Uid" -msgstr "Uid" - -msgid "Code" -msgstr "Code" - -msgid "Name" -msgstr "Name" - msgid "Data element ID scheme" msgstr "Data element ID scheme" @@ -819,6 +815,21 @@ msgstr "" msgid "Date range to export data for" msgstr "Date range to export data for" +msgid "Category ID scheme" +msgstr "Category ID scheme" + +msgid "Category option ID scheme" +msgstr "Category option ID scheme" + +msgid "Category option combo ID scheme" +msgstr "Category option combo ID scheme" + +msgid "Data set ID scheme" +msgstr "Data set ID scheme" + +msgid "Attribute option combo ID scheme" +msgstr "Attribute option combo ID scheme" + msgid "Export data" msgstr "Export data" diff --git a/src/components/ElementSchemes/DataElementIdScheme.jsx b/src/components/ElementSchemes/DataElementIdScheme.jsx deleted file mode 100644 index 51b7c13f6..000000000 --- a/src/components/ElementSchemes/DataElementIdScheme.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useConfig } from '@dhis2/app-runtime' -import i18n from '@dhis2/d2-i18n' -import { SingleSelectFieldFF } from '@dhis2/ui' -import PropTypes from 'prop-types' -import React, { useEffect, useState } from 'react' -import { fetchAttributes } from '../../utils/helper.js' -import { optionsPropType } from '../../utils/options.js' -import { StyledField } from '../index.js' - -const DataElementIdScheme = ({ - name, - label, - dataElementIdSchemeOptions, - dataTest, -}) => { - const { baseUrl } = useConfig() - const [loading, setLoading] = useState(true) - const [schemes, setSchemes] = useState([]) - const [error, setError] = useState(undefined) - - useEffect(() => { - fetchAttributes(`${baseUrl}/api/`, 'dataElementAttribute') - .then((attributes) => setSchemes(attributes)) - .catch((error) => setError(error)) - .finally(() => setLoading(false)) - }, []) - - const validationText = - error && - `${i18n.t( - 'Something went wrong when loading the additional data element ID schemes' - )} : ${error.message}` - - const options = [...dataElementIdSchemeOptions, ...schemes] - return ( - - ) -} - -DataElementIdScheme.propTypes = { - dataElementIdSchemeOptions: optionsPropType.isRequired, - dataTest: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, -} - -export { DataElementIdScheme } diff --git a/src/components/ElementSchemes/EventIdScheme.jsx b/src/components/ElementSchemes/EventIdScheme.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/ElementSchemes/IdScheme.jsx b/src/components/ElementSchemes/IdScheme.jsx deleted file mode 100644 index 390013c69..000000000 --- a/src/components/ElementSchemes/IdScheme.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import { useConfig } from '@dhis2/app-runtime' -import i18n from '@dhis2/d2-i18n' -import { SingleSelectFieldFF } from '@dhis2/ui' -import PropTypes from 'prop-types' -import React, { useState, useEffect } from 'react' -import { fetchAttributes } from '../../utils/helper.js' -import { optionsPropType } from '../../utils/options.js' -import { StyledField } from '../index.js' - -const attributeFoundIn = (attribute, collection) => - !!collection.find(({ value }) => value === attribute.value) - -const IdScheme = ({ name, label, idSchemeOptions, dataTest }) => { - const { baseUrl } = useConfig() - const [loading, setLoading] = useState(true) - const [schemes, setSchemes] = useState([]) - const [error, setError] = useState(undefined) - - useEffect(() => { - const f = async () => { - let err - - const dataElementAttributes = await fetchAttributes( - `${baseUrl}/api/`, - 'dataElementAttribute' - ).catch((error) => (err = error)) - const organisationUnitAttributes = await fetchAttributes( - `${baseUrl}/api/`, - 'organisationUnitAttribute' - ).catch((error) => (err = error)) - - setError(err) - - if (!err) { - const sharedAttributes = dataElementAttributes.reduce( - (shared, attribute) => { - const foundInOrgUnits = attributeFoundIn( - attribute, - organisationUnitAttributes - ) - return foundInOrgUnits ? [...shared, attribute] : shared - }, - [] - ) - - setSchemes(sharedAttributes) - } - - setLoading(false) - } - f() - }, []) - - const validationText = - error && - `${i18n.t( - 'Something went wrong when loading the additional ID schemes' - )} : ${error.message}` - - const options = [...idSchemeOptions, ...schemes] - return ( - - ) -} - -IdScheme.propTypes = { - dataTest: PropTypes.string.isRequired, - idSchemeOptions: optionsPropType.isRequired, - label: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, -} - -export { IdScheme } diff --git a/src/components/ElementSchemes/IdSchemeSelect.jsx b/src/components/ElementSchemes/IdSchemeSelect.jsx new file mode 100644 index 000000000..3b337a95c --- /dev/null +++ b/src/components/ElementSchemes/IdSchemeSelect.jsx @@ -0,0 +1,116 @@ +import { useConfig } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import { SingleSelectFieldFF } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import { fetchAttributes } from '../../utils/helper.js' +import { StyledField } from '../index.js' + +const ID_SCHEME_OPTIONS = (emptyOptionLabel) => [ + { value: '', label: emptyOptionLabel }, + { value: 'UID', label: i18n.t('Uid') }, + { value: 'CODE', label: i18n.t('Code') }, + { value: 'NAME', label: i18n.t('Name') }, +] + +const NO_ATTRIBUTE_TYPES = [] + +const intersectByValue = (a, b) => + a.filter((item) => b.some((other) => other.value === item.value)) + +const IdSchemeSelect = ({ + name, + label, + dataTest, + attributeTypes = NO_ATTRIBUTE_TYPES, + isFallback = false, +}) => { + const { baseUrl } = useConfig() + const [loading, setLoading] = useState(attributeTypes.length > 0) + const [attributeOptions, setAttributeOptions] = useState([]) + const [error, setError] = useState(undefined) + + useEffect(() => { + if (attributeTypes.length === 0) { + return undefined + } + + let cancelled = false + + const loadAttributeSchemes = async () => { + let err + + const schemesByType = await Promise.all( + attributeTypes.map((type) => + fetchAttributes(`${baseUrl}/api/`, type).catch((e) => { + err = e + return [] + }) + ) + ) + + if (cancelled) { + return + } + + setError(err) + if (!err) { + const [first, ...rest] = schemesByType + setAttributeOptions( + rest.reduce( + (shared, schemes) => intersectByValue(shared, schemes), + first + ) + ) + } + setLoading(false) + } + + loadAttributeSchemes() + + return () => { + cancelled = true + } + }, [baseUrl, attributeTypes]) + + const validationText = + error && + `${i18n.t( + 'Something went wrong when loading the additional ID schemes' + )} : ${error.message}` + + // The fallback (common) ID scheme dropdown omits its parameter to defer + // to the server's own default, so "(Default)" fits there. The other, + // per-object-type dropdowns omit theirs to defer to the fallback + // dropdown instead - labeling that choice "(Default)" too would imply + // it means the same thing in both places, so it's called "(Undefined)". + const emptyOptionLabel = isFallback + ? i18n.t('(Default)') + : i18n.t('(Undefined)') + + return ( + + ) +} + +IdSchemeSelect.propTypes = { + dataTest: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + attributeTypes: PropTypes.arrayOf(PropTypes.string), + isFallback: PropTypes.bool, +} + +export { IdSchemeSelect } diff --git a/src/components/ElementSchemes/IdSchemeSelect.test.jsx b/src/components/ElementSchemes/IdSchemeSelect.test.jsx new file mode 100644 index 000000000..98fd48923 --- /dev/null +++ b/src/components/ElementSchemes/IdSchemeSelect.test.jsx @@ -0,0 +1,73 @@ +import { ReactFinalForm } from '@dhis2/ui' +import { render, waitFor } from '@testing-library/react' +import React from 'react' +import { fetchAttributes } from '../../utils/helper.js' +import { IdSchemeSelect } from './IdSchemeSelect.jsx' + +jest.mock('@dhis2/app-runtime', () => ({ + ...jest.requireActual('@dhis2/app-runtime'), + useConfig: () => ({ baseUrl: 'http://test' }), +})) + +jest.mock('../../utils/helper.js', () => ({ + ...jest.requireActual('../../utils/helper.js'), + fetchAttributes: jest.fn(() => Promise.resolve([])), +})) + +const renderInForm = (children) => + render( + {}}> + {() =>
{children}
} +
+ ) + +beforeEach(() => { + fetchAttributes.mockClear() +}) + +describe('IdSchemeSelect', () => { + it('does not fetch attribute schemes when none are configured', () => { + renderInForm( + + ) + + expect(fetchAttributes).not.toHaveBeenCalled() + }) + + it('fetches every configured attribute type', async () => { + renderInForm( + + ) + + await waitFor(() => + expect(fetchAttributes).toHaveBeenCalledWith( + 'http://test/api/', + 'dataElementAttribute' + ) + ) + expect(fetchAttributes).toHaveBeenCalledWith( + 'http://test/api/', + 'organisationUnitAttribute' + ) + }) + + it('renders the field label', () => { + const { getByText } = renderInForm( + + ) + + expect(getByText('Category ID scheme')).toBeInTheDocument() + }) +}) diff --git a/src/components/ElementSchemes/OrgUnitIdScheme.jsx b/src/components/ElementSchemes/OrgUnitIdScheme.jsx deleted file mode 100644 index 47511c650..000000000 --- a/src/components/ElementSchemes/OrgUnitIdScheme.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useConfig } from '@dhis2/app-runtime' -import i18n from '@dhis2/d2-i18n' -import { SingleSelectFieldFF } from '@dhis2/ui' -import PropTypes from 'prop-types' -import React, { useEffect, useState } from 'react' -import { fetchAttributes } from '../../utils/helper.js' -import { optionsPropType } from '../../utils/options.js' -import { StyledField } from '../index.js' - -const OrgUnitIdScheme = ({ name, label, orgUnitIdSchemeOptions, dataTest }) => { - const { baseUrl } = useConfig() - const [loading, setLoading] = useState(true) - const [schemes, setSchemes] = useState([]) - const [error, setError] = useState(undefined) - - useEffect(() => { - fetchAttributes(`${baseUrl}/api/`, 'organisationUnitAttribute') - .then((attributes) => setSchemes(attributes)) - .catch((error) => setError(error)) - .finally(() => setLoading(false)) - }, []) - - const validationText = - error && - `${i18n.t( - 'Something went wrong when loading the additional organisation unit ID schemes' - )} : ${error.message}` - - const options = [...orgUnitIdSchemeOptions, ...schemes] - return ( - - ) -} - -OrgUnitIdScheme.propTypes = { - dataTest: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - orgUnitIdSchemeOptions: optionsPropType.isRequired, -} - -export { OrgUnitIdScheme } diff --git a/src/components/ElementSchemes/idSchemeAttributeTypes.js b/src/components/ElementSchemes/idSchemeAttributeTypes.js new file mode 100644 index 000000000..21ffce5f7 --- /dev/null +++ b/src/components/ElementSchemes/idSchemeAttributeTypes.js @@ -0,0 +1,9 @@ +const CATEGORY_ATTRIBUTE_TYPES = ['categoryAttribute'] +const CATEGORY_OPTION_ATTRIBUTE_TYPES = ['categoryOptionAttribute'] +const DATA_SET_ATTRIBUTE_TYPES = ['dataSetAttribute'] + +export { + CATEGORY_ATTRIBUTE_TYPES, + CATEGORY_OPTION_ATTRIBUTE_TYPES, + DATA_SET_ATTRIBUTE_TYPES, +} diff --git a/src/components/Inputs/DataElementIdScheme.jsx b/src/components/Inputs/DataElementIdScheme.jsx index 5e9085ee8..6b65fe3ef 100644 --- a/src/components/Inputs/DataElementIdScheme.jsx +++ b/src/components/Inputs/DataElementIdScheme.jsx @@ -1,24 +1,20 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' -import { DataElementIdScheme as DataElementIdSchemeGeneric } from '../index.js' +import { IdSchemeSelect } from '../index.js' -const dataElementIdSchemeOptions = [ - { value: 'UID', label: i18n.t('Uid') }, - { value: 'CODE', label: i18n.t('Code') }, - { value: 'NAME', label: i18n.t('Name') }, -] -const defaultDataElementIdSchemeOption = dataElementIdSchemeOptions[0].value +const defaultDataElementIdSchemeOption = '' const NAME = 'dataElementIdScheme' const DATATEST = 'input-data-element-id-scheme' const LABEL = i18n.t('Data element ID scheme') +const ATTRIBUTE_TYPES = ['dataElementAttribute'] const DataElementIdScheme = () => ( - ) diff --git a/src/components/Inputs/EventIdScheme.jsx b/src/components/Inputs/EventIdScheme.jsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/Inputs/IdScheme.jsx b/src/components/Inputs/IdScheme.jsx index 6f88ba78f..90f8b817f 100644 --- a/src/components/Inputs/IdScheme.jsx +++ b/src/components/Inputs/IdScheme.jsx @@ -1,24 +1,21 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' -import { IdScheme as IdSchemeGeneric } from '../index.js' +import { IdSchemeSelect } from '../index.js' -const idSchemeOptions = [ - { value: 'UID', label: i18n.t('Uid') }, - { value: 'CODE', label: i18n.t('Code') }, - { value: 'NAME', label: i18n.t('Name') }, -] -const defaultIdSchemeOption = idSchemeOptions[0].value +const defaultIdSchemeOption = '' const NAME = 'idScheme' const DATATEST = 'input-id-scheme' const LABEL = i18n.t('ID scheme') +const ATTRIBUTE_TYPES = ['dataElementAttribute', 'organisationUnitAttribute'] const IdScheme = () => ( - ) diff --git a/src/components/Inputs/OrgUnitIdScheme.jsx b/src/components/Inputs/OrgUnitIdScheme.jsx index b7cfc18fd..1a40d1191 100644 --- a/src/components/Inputs/OrgUnitIdScheme.jsx +++ b/src/components/Inputs/OrgUnitIdScheme.jsx @@ -1,24 +1,20 @@ import i18n from '@dhis2/d2-i18n' import React from 'react' -import { OrgUnitIdScheme as OrgUnitIdSchemeGeneric } from '../index.js' +import { IdSchemeSelect } from '../index.js' -const orgUnitIdSchemeOptions = [ - { value: 'UID', label: i18n.t('Uid') }, - { value: 'CODE', label: i18n.t('Code') }, - { value: 'NAME', label: i18n.t('Name') }, -] -const defaultOrgUnitIdSchemeOption = orgUnitIdSchemeOptions[0].value +const defaultOrgUnitIdSchemeOption = '' const NAME = 'orgUnitIdScheme' const DATATEST = 'input-org-unit-id-scheme' const LABEL = i18n.t('Organisation unit ID scheme') +const ATTRIBUTE_TYPES = ['organisationUnitAttribute'] const OrgUnitIdScheme = () => ( - ) diff --git a/src/components/index.js b/src/components/index.js index 713db8ab1..aa0757866 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -13,19 +13,22 @@ export { TasksIcon, TEIIcon, } from './Icon/Icon.jsx' -export { DataElementIdScheme } from './ElementSchemes/DataElementIdScheme.jsx' export { DatePicker } from './DatePicker/DatePicker.jsx' export { DatePickerField } from './DatePicker/DatePickerField.jsx' export { FileUpload } from './FileUpload/FileUpload.jsx' export { FormAlerts } from './FormAlerts/FormAlerts.jsx' export { FormField } from './FormField/FormField.jsx' -export { IdScheme } from './ElementSchemes/IdScheme.jsx' +export { IdSchemeSelect } from './ElementSchemes/IdSchemeSelect.jsx' +export { + CATEGORY_ATTRIBUTE_TYPES, + CATEGORY_OPTION_ATTRIBUTE_TYPES, + DATA_SET_ATTRIBUTE_TYPES, +} from './ElementSchemes/idSchemeAttributeTypes.js' export { ImportButtonStrip } from './ImportButtonStrip/ImportButtonStrip.jsx' export { JobOverview } from './JobOverview/JobOverview.jsx' export { JobSummary } from './JobSummary/JobSummary.jsx' export { MiniJobSummary } from './JobSummary/MiniJobSummary/MiniJobSummary.jsx' export { MoreOptions } from './MoreOptions/MoreOptions.jsx' -export { OrgUnitIdScheme } from './ElementSchemes/OrgUnitIdScheme.jsx' export { OrgUnitTreeField } from './OrgUnitTree/OrgUnitTreeField.jsx' export { Page } from './Page/Page.jsx' export { ProgramStages } from './ProgramStages/ProgramStages.jsx' diff --git a/src/pages/DataExport/DataExport.jsx b/src/pages/DataExport/DataExport.jsx index d0f0b987a..e3eabfd50 100644 --- a/src/pages/DataExport/DataExport.jsx +++ b/src/pages/DataExport/DataExport.jsx @@ -9,6 +9,10 @@ import { SchemeContainer, DataIcon, ValidationSummary, + IdSchemeSelect, + CATEGORY_ATTRIBUTE_TYPES, + CATEGORY_OPTION_ATTRIBUTE_TYPES, + DATA_SET_ATTRIBUTE_TYPES, } from '../../components/index.js' import { OrgUnitTree, @@ -63,6 +67,11 @@ const initialValues = { dataElementIdScheme: defaultDataElementIdSchemeOption, orgUnitIdScheme: defaultOrgUnitIdSchemeOption, idScheme: defaultIdSchemeOption, + categoryIdScheme: '', + categoryOptionIdScheme: '', + categoryOptionComboIdScheme: '', + dataSetIdScheme: '', + attributeOptionComboIdScheme: '', } const DataExport = () => { @@ -106,6 +115,40 @@ const DataExport = () => { + + + + + diff --git a/src/pages/DataExport/form-helper.js b/src/pages/DataExport/form-helper.js index b0f92e8e2..74938bf68 100644 --- a/src/pages/DataExport/form-helper.js +++ b/src/pages/DataExport/form-helper.js @@ -7,23 +7,21 @@ import { compressionToName, pathToId, } from '../../utils/helper.js' +import { idSchemeParams } from '../../utils/idSchemeParams.js' -const valuesToParams = ({ - selectedOrgUnits, - includeChildren, - selectedDataSets, - compression, - startDate, - endDate, - includeDeleted, - dataElementIdScheme, - orgUnitIdScheme, - idScheme, -}) => - [ - `dataElementIdScheme=${dataElementIdScheme}`, - `orgUnitIdScheme=${orgUnitIdScheme}`, - `idScheme=${idScheme}`, +const valuesToParams = (values) => { + const { + selectedOrgUnits, + includeChildren, + selectedDataSets, + compression, + startDate, + endDate, + includeDeleted, + } = values + + return [ + ...idSchemeParams(values, 'dataValueSets'), `includeDeleted=${includeDeleted}`, `children=${includeChildren}`, `startDate=${startDate}`, @@ -34,6 +32,7 @@ const valuesToParams = ({ ] .filter((s) => s != '') .join('&') +} const onExport = (baseUrl, setExportEnabled) => async (values) => { setExportEnabled(false) @@ -60,4 +59,4 @@ const validate = (values) => ({ endDate: DATE_AFTER_VALIDATOR(values.endDate, values.startDate), }) -export { onExport, validate } +export { onExport, validate, valuesToParams } diff --git a/src/pages/DataExport/form-helper.test.js b/src/pages/DataExport/form-helper.test.js new file mode 100644 index 000000000..a7ca2c1d6 --- /dev/null +++ b/src/pages/DataExport/form-helper.test.js @@ -0,0 +1,65 @@ +import { valuesToParams } from './form-helper.js' + +const paramKeys = (query) => query.split('&').map((pair) => pair.split('=')[0]) +const paramMap = (query) => + Object.fromEntries(query.split('&').map((pair) => pair.split('='))) + +const baseValues = { + selectedOrgUnits: ['/ImspTQPwCqd'], + includeChildren: true, + selectedDataSets: ['BfMAe6Itzgt'], + compression: '', + startDate: '2020-01-01', + endDate: '2020-02-01', + includeDeleted: false, + dataElementIdScheme: 'UID', + orgUnitIdScheme: 'UID', + idScheme: 'UID', + categoryIdScheme: '', + categoryOptionIdScheme: '', + categoryOptionComboIdScheme: '', + dataSetIdScheme: '', + attributeOptionComboIdScheme: '', +} + +describe('DataExport valuesToParams', () => { + it('emits the explicitly chosen schemes and omits the "(Undefined)" ones', () => { + const params = paramMap(valuesToParams(baseValues)) + + expect(params.idScheme).toBe('UID') + expect(params.dataElementIdScheme).toBe('UID') + expect(params.orgUnitIdScheme).toBe('UID') + expect(paramKeys(valuesToParams(baseValues))).not.toContain( + 'categoryIdScheme' + ) + expect(paramKeys(valuesToParams(baseValues))).not.toContain( + 'dataSetIdScheme' + ) + }) + + it('drops idScheme entirely when it is left at "(Default)"', () => { + const keys = paramKeys(valuesToParams({ ...baseValues, idScheme: '' })) + + expect(keys).not.toContain('idScheme') + }) + + it('includes a category scheme once one is chosen', () => { + const params = paramMap( + valuesToParams({ + ...baseValues, + categoryOptionComboIdScheme: 'CODE', + }) + ) + + expect(params.categoryOptionComboIdScheme).toBe('CODE') + }) + + it('still emits the non-scheme params', () => { + const params = paramMap(valuesToParams(baseValues)) + + expect(params.includeDeleted).toBe('false') + expect(params.children).toBe('true') + expect(params.orgUnit).toBe('ImspTQPwCqd') + expect(params.dataSet).toBe('BfMAe6Itzgt') + }) +}) diff --git a/src/pages/DataImport/DataImport.jsx b/src/pages/DataImport/DataImport.jsx index 6f42c86dc..01c84ac43 100644 --- a/src/pages/DataImport/DataImport.jsx +++ b/src/pages/DataImport/DataImport.jsx @@ -11,6 +11,10 @@ import { SchemeContainer, DataIcon, ValidationSummary, + IdSchemeSelect, + CATEGORY_ATTRIBUTE_TYPES, + CATEGORY_OPTION_ATTRIBUTE_TYPES, + DATA_SET_ATTRIBUTE_TYPES, } from '../../components/index.js' import { FileUpload, @@ -71,6 +75,13 @@ const createInitialValues = (prevJobDetails) => ({ orgUnitIdScheme: prevJobDetails.orgUnitIdScheme || defaultOrgUnitIdSchemeOption, idScheme: prevJobDetails.idScheme || defaultIdSchemeOption, + categoryIdScheme: prevJobDetails.categoryIdScheme || '', + categoryOptionIdScheme: prevJobDetails.categoryOptionIdScheme || '', + categoryOptionComboIdScheme: + prevJobDetails.categoryOptionComboIdScheme || '', + dataSetIdScheme: prevJobDetails.dataSetIdScheme || '', + attributeOptionComboIdScheme: + prevJobDetails.attributeOptionComboIdScheme || '', skipExistingCheck: getInitialBoolValue( prevJobDetails.skipExistingCheck, defaultSkipExistingCheckOption @@ -142,6 +153,40 @@ const DataImport = () => { + + + + + diff --git a/src/pages/DataImport/form-helper.js b/src/pages/DataImport/form-helper.js index b12031ef8..ccf798ac0 100644 --- a/src/pages/DataImport/form-helper.js +++ b/src/pages/DataImport/form-helper.js @@ -1,5 +1,6 @@ import { FORM_ERROR, jobStartedMessage } from '../../utils/final-form.js' import { uploadFile } from '../../utils/helper.js' +import { idSchemeParams } from '../../utils/idSchemeParams.js' const isAsync = true @@ -12,9 +13,6 @@ const onImport = strategy, preheatCache, skipAudit, - dataElementIdScheme, - orgUnitIdScheme, - idScheme, skipExistingCheck, format, firstRowIsHeader, @@ -29,9 +27,7 @@ const onImport = `strategy=${strategy}`, `preheatCache=${preheatCache}`, `skipAudit=${skipAudit}`, - `dataElementIdScheme=${dataElementIdScheme}`, - `orgUnitIdScheme=${orgUnitIdScheme}`, - `idScheme=${idScheme}`, + ...idSchemeParams(values, 'dataValueSets'), `skipExistingCheck=${skipExistingCheck}`, format == 'csv' ? `firstRowIsHeader=${firstRowIsHeader}` : '', ] diff --git a/src/pages/EventExport/form-helper.js b/src/pages/EventExport/form-helper.js index a9e907e08..94300ac91 100644 --- a/src/pages/EventExport/form-helper.js +++ b/src/pages/EventExport/form-helper.js @@ -4,38 +4,26 @@ import { } from '../../components/DatePicker/DatePickerField.jsx' import { ALL_VALUE } from '../../hooks/useProgramStages.js' import { fetchAndDownload, pathToId } from '../../utils/helper.js' +import { idSchemeParams } from '../../utils/idSchemeParams.js' -const onExport = (baseUrl, setExportEnabled) => async (values) => { - setExportEnabled(false) - +const valuesToParams = (values) => { const { selectedOrgUnits, selectedPrograms, programStage, - format, - compression, occurredAfter, occurredBefore, includeDeleted, - dataElementIdScheme, - orgUnitIdScheme, - idScheme, inclusion, } = values - // generate URL and redirect - const apiBaseUrl = `${baseUrl}/api/tracker/` - const endpoint = `events` - const endpointExtension = compression ? `${format}.${compression}` : format - const downloadUrlParams = [ + return [ 'paging=false', 'totalPages=false', `orgUnit=${pathToId(selectedOrgUnits[0])}`, `program=${selectedPrograms}`, `includeDeleted=${includeDeleted}`, - `dataElementIdScheme=${dataElementIdScheme}`, - `orgUnitIdScheme=${orgUnitIdScheme}`, - `idScheme=${idScheme}`, + ...idSchemeParams(values, 'tracker'), `occurredAfter=${occurredAfter}`, `occurredBefore=${occurredBefore}`, `orgUnitMode=${inclusion}`, @@ -43,6 +31,18 @@ const onExport = (baseUrl, setExportEnabled) => async (values) => { ] .filter((s) => s != '') .join('&') +} + +const onExport = (baseUrl, setExportEnabled) => async (values) => { + setExportEnabled(false) + + const { format, compression } = values + + // generate URL and redirect + const apiBaseUrl = `${baseUrl}/api/tracker/` + const endpoint = `events` + const endpointExtension = compression ? `${format}.${compression}` : format + const downloadUrlParams = valuesToParams(values) const url = `${apiBaseUrl}${endpoint}.${endpointExtension}?${downloadUrlParams}` try { @@ -60,4 +60,4 @@ const validate = (values) => ({ endDate: DATE_AFTER_VALIDATOR(values.endDate, values.startDate), }) -export { onExport, validate } +export { onExport, validate, valuesToParams } diff --git a/src/pages/EventExport/form-helper.test.js b/src/pages/EventExport/form-helper.test.js new file mode 100644 index 000000000..302b80c66 --- /dev/null +++ b/src/pages/EventExport/form-helper.test.js @@ -0,0 +1,47 @@ +import { valuesToParams } from './form-helper.js' + +const paramKeys = (query) => query.split('&').map((pair) => pair.split('=')[0]) +const paramMap = (query) => + Object.fromEntries(query.split('&').map((pair) => pair.split('='))) + +const baseValues = { + selectedOrgUnits: ['/ImspTQPwCqd'], + selectedPrograms: 'lxAQ7Zs9VYR', + programStage: 'ALL', + format: 'json', + compression: '', + occurredAfter: '2020-01-01', + occurredBefore: '2020-02-01', + includeDeleted: false, + dataElementIdScheme: 'UID', + orgUnitIdScheme: 'UID', + idScheme: 'UID', + inclusion: 'SELECTED', +} + +describe('EventExport valuesToParams', () => { + it('emits the explicitly chosen schemes', () => { + const params = paramMap(valuesToParams(baseValues)) + + expect(params.idScheme).toBe('UID') + expect(params.dataElementIdScheme).toBe('UID') + expect(params.orgUnitIdScheme).toBe('UID') + }) + + it('drops a scheme left at "(Undefined)"', () => { + const keys = paramKeys( + valuesToParams({ ...baseValues, dataElementIdScheme: '' }) + ) + + expect(keys).not.toContain('dataElementIdScheme') + expect(keys).toContain('idScheme') + }) + + it('never emits the dataValueSets-only schemes', () => { + const keys = paramKeys( + valuesToParams({ ...baseValues, categoryIdScheme: 'CODE' }) + ) + + expect(keys).not.toContain('categoryIdScheme') + }) +}) diff --git a/src/pages/EventImport/form-helper.js b/src/pages/EventImport/form-helper.js index 5dd6c3a1f..817c61654 100644 --- a/src/pages/EventImport/form-helper.js +++ b/src/pages/EventImport/form-helper.js @@ -1,29 +1,23 @@ import { FORM_ERROR, jobStartedMessage } from '../../utils/final-form.js' import { uploadFile } from '../../utils/helper.js' +import { idSchemeParams } from '../../utils/idSchemeParams.js' const isAsync = true const onImport = ({ baseUrl, setProgress, addTask, setShowFullSummaryTask }) => async (values) => { - const { - dryRun, - files, - format, - dataElementIdScheme, - orgUnitIdScheme, - idScheme, - } = values + const { dryRun, files, format } = values // send xhr const apiBaseUrl = `${baseUrl}/api/tracker` const params = [ `async=${isAsync}`, `importMode=${dryRun ? 'validate' : 'commit'}`, - `dataElementIdScheme=${dataElementIdScheme}`, - `orgUnitIdScheme=${orgUnitIdScheme}`, - `idScheme=${idScheme}`, - ].join('&') + ...idSchemeParams(values, 'tracker'), + ] + .filter((s) => s != '') + .join('&') const url = `${apiBaseUrl}?${params}` try { diff --git a/src/pages/TEIExport/form-helper.js b/src/pages/TEIExport/form-helper.js index ea5d0cdb9..ddde09d85 100644 --- a/src/pages/TEIExport/form-helper.js +++ b/src/pages/TEIExport/form-helper.js @@ -5,100 +5,105 @@ import { } from '../../components/DatePicker/DatePickerField.jsx' import { OU_MODE_MANUAL_VALUE } from '../../components/Inputs/index.js' import { fetchAndDownload, pathToId } from '../../utils/helper.js' - -// calculate minimum set of parameters based on given filters -const valuesToParams = ({ - selectedOrgUnits, - selectedUsers, - selectedPrograms, - selectedTETypes, - orgUnitMode, - inclusion, - format, - includeDeleted, - dataElementIdScheme, - orgUnitIdScheme, - idScheme, +import { idSchemeEntries } from '../../utils/idSchemeParams.js' + +const compact = (obj) => + Object.fromEntries(Object.entries(obj).filter(([, value]) => value)) + +// include selected org.units only when manual selection is selected +// orgUnitMode is then stored in the `inclusion` field +const orgUnitParams = ({ orgUnitMode, inclusion, selectedOrgUnits }) => + orgUnitMode === OU_MODE_MANUAL_VALUE + ? { + orgUnits: selectedOrgUnits.map((o) => pathToId(o)).join(','), + orgUnitMode: inclusion, + } + : {} + +const assignedUserParams = ({ assignedUserModeFilter, assignedUserMode, - teiTypeFilter, - programStatus, - followUp, - enrollmentEnrolledAfter, - enrollmentEnrolledBefore, - lastUpdatedFilter, - updatedAfter, - updatedBefore, - updatedWithin, + selectedUsers, }) => { - const minParams = { - fields: '*,enrollments[*,events[*]]', - orgUnitMode: orgUnitMode, - format: format, - includeDeleted: includeDeleted.toString(), - dataElementIdScheme: dataElementIdScheme, - orgUnitIdScheme: orgUnitIdScheme, - idScheme: idScheme, - paging: false, - totalPages: false, + if (!assignedUserModeFilter) { + return {} } - - // include selected org.units only when manual selection is selected - // orgUnitMode is then stored in the `inclusion` field - if (orgUnitMode === OU_MODE_MANUAL_VALUE) { - minParams.orgUnits = selectedOrgUnits.map((o) => pathToId(o)).join(',') - minParams.orgUnitMode = inclusion + return { + assignedUserMode, + ...(assignedUserMode === 'PROVIDED' + ? { assignedUsers: selectedUsers.join(',') } + : {}), } +} - if (assignedUserModeFilter) { - minParams.assignedUserMode = assignedUserMode - - if (assignedUserMode == 'PROVIDED') { - minParams.assignedUsers = selectedUsers.join(',') - } +const programFilterParams = (values) => { + if (values.teiTypeFilter !== 'PROGRAM') { + return {} } - - if (teiTypeFilter == 'PROGRAM') { - minParams.program = selectedPrograms - if (programStatus) { - // programStatus = ALL is now the same - // as not providing a value for this param at all - minParams.programStatus = programStatus - } - - if (followUp !== 'ALL') { - minParams.followUp = followUp - } - - if (enrollmentEnrolledAfter) { - minParams.enrollmentEnrolledAfter = enrollmentEnrolledAfter - } - - if (enrollmentEnrolledBefore) { - minParams.enrollmentEnrolledBefore = enrollmentEnrolledBefore - } + const { + selectedPrograms, + programStatus, + followUp, + enrollmentEnrolledAfter, + enrollmentEnrolledBefore, + } = values + return { + program: selectedPrograms, + // programStatus = ALL is now the same + // as not providing a value for this param at all + ...compact({ + programStatus, + followUp: followUp === 'ALL' ? '' : followUp, + enrollmentEnrolledAfter, + enrollmentEnrolledBefore, + }), } +} - if (teiTypeFilter == 'TE') { - minParams.trackedEntityType = selectedTETypes +const lastUpdatedParams = ({ + lastUpdatedFilter, + updatedAfter, + updatedBefore, + updatedWithin, +}) => { + if (lastUpdatedFilter === 'DATE') { + return compact({ updatedAfter, updatedBefore }) } - - if (lastUpdatedFilter == 'DATE') { - if (updatedAfter) { - minParams.updatedAfter = updatedAfter - } - - if (updatedBefore) { - minParams.updatedBefore = updatedBefore - } + if (lastUpdatedFilter === 'DURATION') { + return { updatedWithin } } + return {} +} + +// calculate minimum set of parameters based on given filters +const valuesToParams = (values) => { + const { + orgUnitMode, + format, + includeDeleted, + teiTypeFilter, + selectedTETypes, + } = values - if (lastUpdatedFilter == 'DURATION') { - minParams.updatedWithin = updatedWithin + const minParams = { + fields: '*,enrollments[*,events[*]]', + orgUnitMode, + format, + includeDeleted: includeDeleted.toString(), + ...Object.fromEntries(idSchemeEntries(values, 'tracker')), + paging: false, + totalPages: false, + ...orgUnitParams(values), + ...assignedUserParams(values), + ...programFilterParams(values), + ...(teiTypeFilter === 'TE' + ? { trackedEntityType: selectedTETypes } + : {}), + ...lastUpdatedParams(values), } - return Object.keys(minParams) - .map((param) => `${param}=${minParams[param]}`) + return Object.entries(minParams) + .map(([param, value]) => `${param}=${value}`) .join('&') } @@ -169,4 +174,4 @@ const validate = (values) => { return errors } -export { onExport, validate } +export { onExport, validate, valuesToParams } diff --git a/src/pages/TEIExport/form-helper.test.js b/src/pages/TEIExport/form-helper.test.js new file mode 100644 index 000000000..aa2e68b5e --- /dev/null +++ b/src/pages/TEIExport/form-helper.test.js @@ -0,0 +1,45 @@ +import { valuesToParams } from './form-helper.js' + +const paramMap = (query) => + Object.fromEntries(query.split('&').map((pair) => pair.split('='))) + +const baseValues = { + selectedOrgUnits: ['/ImspTQPwCqd'], + selectedUsers: [], + selectedPrograms: 'lxAQ7Zs9VYR', + selectedTETypes: [], + orgUnitMode: 'SELECTED', + inclusion: 'SELECTED', + format: 'json', + includeDeleted: false, + dataElementIdScheme: 'UID', + orgUnitIdScheme: 'UID', + idScheme: 'UID', + assignedUserModeFilter: false, + teiTypeFilter: 'PROGRAM', + programStatus: '', + followUp: 'ALL', + enrollmentEnrolledAfter: '', + enrollmentEnrolledBefore: '', + lastUpdatedFilter: 'NONE', + updatedAfter: '', + updatedBefore: '', + updatedWithin: '', +} + +describe('TEIExport valuesToParams', () => { + it('emits the explicitly chosen schemes', () => { + const params = paramMap(valuesToParams(baseValues)) + + expect(params.idScheme).toBe('UID') + expect(params.dataElementIdScheme).toBe('UID') + expect(params.orgUnitIdScheme).toBe('UID') + }) + + it('drops a scheme left at "(Default)"', () => { + const params = paramMap(valuesToParams({ ...baseValues, idScheme: '' })) + + expect(params.idScheme).toBeUndefined() + expect(params.dataElementIdScheme).toBe('UID') + }) +}) diff --git a/src/pages/TEIImport/form-helper.js b/src/pages/TEIImport/form-helper.js index 877fffb78..1b32beedc 100644 --- a/src/pages/TEIImport/form-helper.js +++ b/src/pages/TEIImport/form-helper.js @@ -1,5 +1,6 @@ import { FORM_ERROR, jobStartedMessage } from '../../utils/final-form.js' import { uploadFile } from '../../utils/helper.js' +import { idSchemeParams } from '../../utils/idSchemeParams.js' const onImport = ({ baseUrl, setProgress, addTask, setShowFullSummaryTask }) => @@ -19,9 +20,6 @@ const onImport = skipValidation, inclusionStrategy, isAsync, - dataElementIdScheme, - orgUnitIdScheme, - idScheme, } = values // send xhr @@ -39,9 +37,7 @@ const onImport = `skipValidation=${skipValidation}`, `inclusionStrategy=${inclusionStrategy}`, `async=${isAsync}`, - `dataElementIdScheme=${dataElementIdScheme}`, - `orgUnitIdScheme=${orgUnitIdScheme}`, - `idScheme=${idScheme}`, + ...idSchemeParams(values, 'tracker'), ] .filter((s) => s != '') .join('&') diff --git a/src/utils/helper.js b/src/utils/helper.js index a191a648d..d0dfaef9e 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -225,8 +225,6 @@ const exportErrorAlert = (source, message) => ({ ], }) -// fetches the export URL, and only triggers the download if the server -// responded with a success status otherwise returns an error alert const fetchAndDownload = async (url, source) => { try { const response = await fetch(url, { credentials: 'include' }) @@ -237,7 +235,6 @@ const fetchAndDownload = async (url, source) => { const body = await response.json() message = body.message || message } catch (e) { - // response body wasn't JSON, fall back to the generic message console.error( `${source}-export: failed to parse error response`, e @@ -275,7 +272,6 @@ const getInitialBoolValue = (prevValue, defaultValue) => { return prevValue } -// adds a digit group separator matching the given locale const formatNumber = (value, locale) => { if (value === undefined || value === null || value === '') { return value diff --git a/src/utils/idSchemeParams.js b/src/utils/idSchemeParams.js new file mode 100644 index 000000000..17f34c89e --- /dev/null +++ b/src/utils/idSchemeParams.js @@ -0,0 +1,23 @@ +const ID_SCHEME_PARAMS_BY_ENDPOINT = { + dataValueSets: [ + 'dataElementIdScheme', + 'orgUnitIdScheme', + 'idScheme', + 'categoryIdScheme', + 'categoryOptionIdScheme', + 'categoryOptionComboIdScheme', + 'dataSetIdScheme', + 'attributeOptionComboIdScheme', + ], + tracker: ['dataElementIdScheme', 'orgUnitIdScheme', 'idScheme'], +} + +const idSchemeEntries = (values, endpoint) => + (ID_SCHEME_PARAMS_BY_ENDPOINT[endpoint] || []) + .filter((key) => values[key]) + .map((key) => [key, values[key]]) + +const idSchemeParams = (values, endpoint) => + idSchemeEntries(values, endpoint).map(([key, value]) => `${key}=${value}`) + +export { ID_SCHEME_PARAMS_BY_ENDPOINT, idSchemeEntries, idSchemeParams } diff --git a/src/utils/idSchemeParams.test.js b/src/utils/idSchemeParams.test.js new file mode 100644 index 000000000..3e0edc710 --- /dev/null +++ b/src/utils/idSchemeParams.test.js @@ -0,0 +1,61 @@ +import { idSchemeEntries, idSchemeParams } from './idSchemeParams.js' + +describe('idSchemeParams', () => { + it('emits only the params that have a value', () => { + const values = { + dataElementIdScheme: '', + orgUnitIdScheme: 'CODE', + idScheme: 'UID', + } + + expect(idSchemeParams(values, 'tracker')).toEqual([ + 'orgUnitIdScheme=CODE', + 'idScheme=UID', + ]) + }) + + it('emits nothing when every scheme is left at "(Default)"', () => { + const values = { + dataElementIdScheme: '', + orgUnitIdScheme: '', + idScheme: '', + } + + expect(idSchemeParams(values, 'tracker')).toEqual([]) + }) + + it('supports the category / data set / attribute option combo params for dataValueSets', () => { + const values = { + categoryIdScheme: 'NAME', + dataSetIdScheme: 'CODE', + attributeOptionComboIdScheme: 'UID', + } + + expect(idSchemeParams(values, 'dataValueSets')).toEqual([ + 'categoryIdScheme=NAME', + 'dataSetIdScheme=CODE', + 'attributeOptionComboIdScheme=UID', + ]) + }) + + it('ignores params the endpoint does not support even when set', () => { + const values = { categoryIdScheme: 'NAME', idScheme: 'UID' } + + expect(idSchemeParams(values, 'tracker')).toEqual(['idScheme=UID']) + }) + + it('returns [key, value] pairs from idSchemeEntries', () => { + const values = { orgUnitIdScheme: 'CODE', idScheme: '' } + + expect(idSchemeEntries(values, 'tracker')).toEqual([ + ['orgUnitIdScheme', 'CODE'], + ]) + }) + + it('returns an empty array for an unknown endpoint instead of throwing', () => { + const values = { orgUnitIdScheme: 'CODE' } + + expect(idSchemeParams(values, 'notAnEndpoint')).toEqual([]) + expect(idSchemeEntries(values, 'notAnEndpoint')).toEqual([]) + }) +})