From b7577709a1aa0fcd7e0e45a84771bf79a9de47eb Mon Sep 17 00:00:00 2001 From: Bjorn Sandvik Date: Tue, 12 Sep 2023 10:51:02 +0200 Subject: [PATCH] feat: custom calculations for thematic layer (DHIS2-15474) (#2745) --- .../integration/layers/thematiclayer.cy.js | 81 ++++++++++++++++++- i18n/en.pot | 15 ++++ .../calculations/CalculationSelect.js | 62 ++++++++++++++ .../styles/CalculationSelect.module.css | 3 + src/components/core/SelectField.js | 6 ++ .../edit/thematic/ThematicDialog.js | 18 +++++ .../edit/thematic/ValueTypeSelect.js | 40 ++++----- src/constants/dimension.js | 6 ++ 8 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 src/components/calculations/CalculationSelect.js create mode 100644 src/components/calculations/styles/CalculationSelect.module.css diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js index d9966fc7d..4dacacc0b 100644 --- a/cypress/integration/layers/thematiclayer.cy.js +++ b/cypress/integration/layers/thematiclayer.cy.js @@ -6,7 +6,7 @@ import { expectContextMenuOptions, } from '../../elements/map_context_menu.js' import { ThematicLayer } from '../../elements/thematic_layer.js' -import { CURRENT_YEAR } from '../../support/util.js' +import { CURRENT_YEAR, getApiBaseUrl } from '../../support/util.js' const INDICATOR_NAME = 'VCCT post-test counselling rate' @@ -136,4 +136,83 @@ context('Thematic Layers', () => { { name: VIEW_PROFILE }, ]) }) + + // TODO - update demo database with calculations instead of creating on the fly + it('adds a thematic layer with a calculation', () => { + const timestamp = new Date().toUTCString().slice(-24, -4) + const calculationName = `map calc ${timestamp}` + + // add a calculation + cy.request('POST', `${getApiBaseUrl()}/api/expressionDimensionItems`, { + name: calculationName, + shortName: calculationName, + expression: '#{fbfJHSPpUQD}/2', + }).then((response) => { + expect(response.status).to.eq(201) + + const calculationUid = response.body.response.uid + + // open thematic dialog + cy.getByDataTest('add-layer-button').click() + cy.getByDataTest('addlayeritem-thematic').click() + + // choose "Calculation" in item type + cy.getByDataTest('thematic-layer-value-type-select').click() + cy.contains('Calculations').click() + + // assert that the label on the Calculation select is "Calculation" + cy.getByDataTest('calculationselect-label').contains('Calculation') + + // click to open the calculation select + cy.getByDataTest('calculationselect').click() + + // check search box exists "Type to filter options" + cy.getByDataTest('dhis2-uicore-popper') + .find('input[type="text"]') + .should('have.attr', 'placeholder', 'Type to filter options') + + // search for something that doesn't exist + cy.getByDataTest('dhis2-uicore-popper') + .find('input[type="text"]') + .type('foo') + + cy.getByDataTest('dhis2-uicore-select-menu-menuwrapper') + .contains('No options found') + .should('be.visible') + + // try search for something that exists + cy.getByDataTest('dhis2-uicore-popper') + .find('input[type="text"]') + .clear() + + cy.getByDataTest('dhis2-uicore-popper') + .find('input[type="text"]') + .type(calculationName) + + cy.getByDataTest('dhis2-uicore-select-menu-menuwrapper') + .contains(calculationName) + .should('be.visible') + + // select the calculation and close dialog + cy.contains(calculationName).click() + + cy.getByDataTest('dhis2-uicore-modalactions') + .contains('Add layer') + .click() + + // check the layer card title + cy.getByDataTest('layercard') + .contains(calculationName, { timeout: 50000 }) + .should('be.visible') + + // check the map canvas is displayed + cy.get('canvas.maplibregl-canvas').should('be.visible') + + // delete the calculation + cy.request( + 'DELETE', + `${getApiBaseUrl()}/api/expressionDimensionItems/${calculationUid}` + ) + }) + }) }) diff --git a/i18n/en.pot b/i18n/en.pot index 2ff7557e9..34bbf28dd 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -17,6 +17,15 @@ msgstr "Map \"{{- name}}\" is saved." msgid "Failed to save map: {{message}}" msgstr "Failed to save map: {{message}}" +msgid "Calculation" +msgstr "Calculation" + +msgid "No calculations found" +msgstr "No calculations found" + +msgid "Calculations can be created in the Data Visualizer app." +msgstr "Calculations can be created in the Data Visualizer app." + msgid "Classification" msgstr "Classification" @@ -421,6 +430,9 @@ msgstr "Event data item is required" msgid "Program indicator is required" msgstr "Program indicator is required" +msgid "Calculation is required" +msgstr "Calculation is required" + msgid "Period is required" msgstr "Period is required" @@ -436,6 +448,9 @@ msgstr "Event data items" msgid "Program indicators" msgstr "Program indicators" +msgid "Calculations" +msgstr "Calculations" + msgid "Item type" msgstr "Item type" diff --git a/src/components/calculations/CalculationSelect.js b/src/components/calculations/CalculationSelect.js new file mode 100644 index 000000000..8bdc94ad6 --- /dev/null +++ b/src/components/calculations/CalculationSelect.js @@ -0,0 +1,62 @@ +import { useDataQuery } from '@dhis2/app-runtime' +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { SelectField, Help } from '../core/index.js' +import { useUserSettings } from '../UserSettingsProvider.js' +import styles from './styles/CalculationSelect.module.css' + +// Load all calculations +const CALCULATIONS_QUERY = { + calculations: { + resource: 'expressionDimensionItems', + params: ({ nameProperty }) => ({ + fields: ['id', `${nameProperty}~rename(name)`], + paging: false, + }), + }, +} + +const CalculationSelect = ({ calculation, className, errorText, onChange }) => { + const { nameProperty } = useUserSettings() + const { loading, error, data } = useDataQuery(CALCULATIONS_QUERY, { + variables: { nameProperty }, + }) + + const items = data?.calculations.expressionDimensionItems + const value = calculation?.id + + return ( +
+ onChange(dataItem, 'calculation')} + className={className} + emptyText={i18n.t('No calculations found')} + errorText={ + error?.message || + (!calculation && errorText ? errorText : null) + } + filterable={true} + dataTest="calculationselect" + /> + + {i18n.t( + 'Calculations can be created in the Data Visualizer app.' + )} + +
+ ) +} + +CalculationSelect.propTypes = { + onChange: PropTypes.func.isRequired, + calculation: PropTypes.object, + className: PropTypes.string, + errorText: PropTypes.string, +} + +export default CalculationSelect diff --git a/src/components/calculations/styles/CalculationSelect.module.css b/src/components/calculations/styles/CalculationSelect.module.css new file mode 100644 index 000000000..d8cf160d2 --- /dev/null +++ b/src/components/calculations/styles/CalculationSelect.module.css @@ -0,0 +1,3 @@ +.calculationSelect { + margin-bottom: var(--spacers-dp16); +} diff --git a/src/components/core/SelectField.js b/src/components/core/SelectField.js index ecfed175d..fa21e24c9 100644 --- a/src/components/core/SelectField.js +++ b/src/components/core/SelectField.js @@ -17,6 +17,7 @@ import styles from './styles/InputField.module.css' const SelectField = (props) => { const { dense = true, + emptyText, errorText, helpText, warning, @@ -25,6 +26,7 @@ const SelectField = (props) => { prefix, loading, multiple, + filterable, disabled, onChange, className, @@ -65,12 +67,14 @@ const SelectField = (props) => { label={label} prefix={prefix} selected={!isLoading ? selected : undefined} + filterable={filterable} disabled={disabled} loading={isLoading} error={!!errorText} warning={!!warning} validationText={warning ? warning : errorText} helpText={helpText} + empty={emptyText} onChange={onSelectChange} dataTest={dataTest} > @@ -88,7 +92,9 @@ SelectField.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, + emptyText: PropTypes.string, // If set, shows empty text when no options errorText: PropTypes.string, // If set, shows the error message below the SelectField + filterable: PropTypes.bool, helpText: PropTypes.string, // If set, shows the help text below the SelectField items: PropTypes.arrayOf( PropTypes.shape({ diff --git a/src/components/edit/thematic/ThematicDialog.js b/src/components/edit/thematic/ThematicDialog.js index 263c4120f..7470902a9 100644 --- a/src/components/edit/thematic/ThematicDialog.js +++ b/src/components/edit/thematic/ThematicDialog.js @@ -36,6 +36,7 @@ import { } from '../../../util/analytics.js' import { isPeriodAvailable } from '../../../util/periods.js' import { getStartEndDateError } from '../../../util/time.js' +import CalculationSelect from '../../calculations/CalculationSelect.js' import NumericLegendStyle from '../../classification/NumericLegendStyle.js' import { Tab, Tabs } from '../../core/index.js' import DataElementGroupSelect from '../../dataElement/DataElementGroupSelect.js' @@ -255,6 +256,7 @@ class ThematicDialog extends Component { dataElementError, dataSetError, programError, + calculationError, eventDataItemError, programIndicatorError, periodTypeError, @@ -408,6 +410,14 @@ class ThematicDialog extends Component { /> ), ]} + {valueType === dimConf.calculation.objectName && ( + + )} @@ -609,6 +619,14 @@ class ThematicDialog extends Component { } } + if (valueType === dimConf.calculation.objectName && !dataItem) { + return this.setErrorState( + 'calculationError', + i18n.t('Calculation is required'), + 'data' + ) + } + if (!period && periodType !== START_END_DATES) { return this.setErrorState( 'periodError', diff --git a/src/components/edit/thematic/ValueTypeSelect.js b/src/components/edit/thematic/ValueTypeSelect.js index 018b87ab8..0726f9b86 100644 --- a/src/components/edit/thematic/ValueTypeSelect.js +++ b/src/components/edit/thematic/ValueTypeSelect.js @@ -1,11 +1,29 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React from 'react' +import React, { useMemo } from 'react' import { dimConf } from '../../../constants/dimension.js' import { SelectField } from '../../core/index.js' -const ValueTypeSelect = (props) => { - const { value, onChange, className } = props +const getValueTypes = () => [ + { id: dimConf.indicator.objectName, name: i18n.t('Indicator') }, + { id: dimConf.dataElement.objectName, name: i18n.t('Data element') }, + { id: dimConf.dataSet.objectName, name: i18n.t('Reporting rates') }, + { + id: dimConf.eventDataItem.objectName, + name: i18n.t('Event data items'), + }, + { + id: dimConf.programIndicator.objectName, + name: i18n.t('Program indicators'), + }, + { + id: dimConf.calculation.objectName, + name: i18n.t('Calculations'), + }, +] + +const ValueTypeSelect = ({ value, onChange, className }) => { + const items = useMemo(() => getValueTypes(), []) // If value type is data element operand, make it data element const type = @@ -13,21 +31,6 @@ const ValueTypeSelect = (props) => { ? dimConf.dataElement.objectName : value - // TODO: Avoid creating on each render (needs to be created after i18next contains translations - const items = [ - { id: dimConf.indicator.objectName, name: i18n.t('Indicator') }, - { id: dimConf.dataElement.objectName, name: i18n.t('Data element') }, - { id: dimConf.dataSet.objectName, name: i18n.t('Reporting rates') }, - { - id: dimConf.eventDataItem.objectName, - name: i18n.t('Event data items'), - }, - { - id: dimConf.programIndicator.objectName, - name: i18n.t('Program indicators'), - }, - ] - return ( { value={type} onChange={(valueType) => onChange(valueType.id)} className={className} + dataTest="thematic-layer-value-type-select" /> ) } diff --git a/src/constants/dimension.js b/src/constants/dimension.js index 16b6d48ae..3834de2b0 100644 --- a/src/constants/dimension.js +++ b/src/constants/dimension.js @@ -64,6 +64,12 @@ export const dimConf = { objectName: 'pi', itemType: 'PROGRAM_INDICATOR', }, + calculation: { + value: 'expressionDimensionItems', + dimensionName: 'dx', + objectName: 'ed', // Created by Bjorn, don't seem to be in use when the map is saved + itemType: 'EXPRESSION_DIMENSION_ITEM', + }, period: { id: 'period', value: 'period',