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 fbce2a3d2..86649bf58 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/components/layers/overlays/OverlayCard.js b/src/components/layers/overlays/OverlayCard.js index 7c1405dff..cfd1e86e3 100644 --- a/src/components/layers/overlays/OverlayCard.js +++ b/src/components/layers/overlays/OverlayCard.js @@ -105,7 +105,10 @@ const OverlayCard = ({ await set(currentAO) // Open it in another app - window.location.href = `${baseUrl}/${APP_URLS[type]}/#/currentAnalyticalObject` + window.open( + `${baseUrl}/${APP_URLS[type]}/#/currentAnalyticalObject`, + '_blank' + ) } : undefined } diff --git a/src/components/orgunits/OrgUnitData.js b/src/components/orgunits/OrgUnitData.js index bce650e02..4108d34d1 100644 --- a/src/components/orgunits/OrgUnitData.js +++ b/src/components/orgunits/OrgUnitData.js @@ -23,7 +23,7 @@ const ORGUNIT_PROFILE_QUERY = { // Only YEARLY period type is supported in first version const periodType = 'YEARLY' const currentYear = String(new Date().getFullYear()) -const periods = getFixedPeriodsByType(periodType, currentYear) +const periods = getFixedPeriodsByType({ periodType, currentYear }) const defaultPeriod = filterFuturePeriods(periods)[0] || periods[0] /* diff --git a/src/components/periods/PeriodSelect.js b/src/components/periods/PeriodSelect.js index f020e3ce4..f8261ac88 100644 --- a/src/components/periods/PeriodSelect.js +++ b/src/components/periods/PeriodSelect.js @@ -7,7 +7,8 @@ import { } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { Component } from 'react' +import React, { useState, useMemo, useCallback, useEffect } from 'react' +import usePrevious from '../../hooks/usePrevious.js' import { getFixedPeriodsByType, filterFuturePeriods, @@ -16,124 +17,118 @@ import { getYear } from '../../util/time.js' import { SelectField } from '../core/index.js' import styles from './styles/PeriodSelect.module.css' -class PeriodSelect extends Component { - static propTypes = { - onChange: PropTypes.func.isRequired, - className: PropTypes.string, - errorText: PropTypes.string, - period: PropTypes.shape({ - id: PropTypes.string.isRequired, - startDate: PropTypes.string, - }), - periodType: PropTypes.string, - } - - state = { - year: null, - periods: null, - } - - componentDidMount() { - this.setPeriods() - } - - componentDidUpdate(prevProps, prevState) { - const { periodType, period, onChange } = this.props - const { year, periods } = this.state - - if (periodType !== prevProps.periodType) { - this.setPeriods() - } else if (periods && !period) { - onChange(filterFuturePeriods(periods)[0] || periods[0]) // Autoselect most recent period - } - - // Change period if year is changed (but keep period index) - if (period && prevState.periods && year !== prevState.year) { - const periodIndex = prevState.periods.findIndex( - (item) => item.id === period.id - ) - onChange(periods[periodIndex]) - } - } - - render() { - const { periodType, period, onChange, className, errorText } = - this.props - const { periods } = this.state - - if (!periods) { - return null +const PeriodSelect = ({ + onChange, + className, + errorText, + firstDate, + lastDate, + period, + periodType, +}) => { + const [year, setYear] = useState(getYear(period?.startDate || lastDate)) + const prevYear = usePrevious(year) + + // Set periods when periodType or year changes + /* eslint-disable react-hooks/exhaustive-deps */ + const periods = useMemo( + () => + periodType + ? getFixedPeriodsByType({ + periodType, + year, + firstDate, + lastDate, + }) + : [period], // saved map period (not included in depency array by design) + [periodType, year, firstDate, lastDate] + ) + /* eslint-enable react-hooks/exhaustive-deps */ + + // Increment/decrement year + const changeYear = useCallback( + (change) => { + const newYear = year + change + + if ( + (!firstDate || newYear >= getYear(firstDate)) && + (!lastDate || newYear <= getYear(lastDate)) + ) { + setYear(newYear) + } + }, + [year, firstDate, lastDate] + ) + + // Autoselect most recent period + useEffect(() => { + if (!period) { + onChange(filterFuturePeriods(periods)[0] || periods[0]) } + }, [period, periods, year, onChange]) - const value = - period && periods.some((p) => p.id === period.id) ? period.id : null - - return ( -
- - {periodType && ( -
- -
- )} -
- ) - } - - setPeriods() { - const { periodType, period } = this.props - const year = this.state.year || getYear(period && period.startDate) - let periods + // Keep the same period position when year changes + useEffect(() => { + if (period && !periods.some((p) => p.id === period.id)) { + const periodId = period.id.replace(prevYear, year) - if (periodType) { - periods = getFixedPeriodsByType(periodType, year) - } else if (period) { - periods = [period] // If period is loaded in favorite + onChange(periods.find((p) => p.id === periodId)) } + }, [period, periods, year, prevYear, onChange]) - this.setState({ periods, year }) + if (!periods) { + return null } - nextYear = () => { - this.changeYear(1) - } - - previousYear = () => { - this.changeYear(-1) - } - - changeYear = (change) => { - const { periodType } = this.props - const year = this.state.year + change + const value = + period && periods.some((p) => p.id === period.id) ? period.id : null + + return ( +
+ + {periodType && ( +
+ +
+ )} +
+ ) +} - this.setState({ - year, - periods: getFixedPeriodsByType(periodType, year), - }) - } +PeriodSelect.propTypes = { + onChange: PropTypes.func.isRequired, + className: PropTypes.string, + errorText: PropTypes.string, + firstDate: PropTypes.string, + lastDate: PropTypes.string, + period: PropTypes.shape({ + id: PropTypes.string.isRequired, + startDate: PropTypes.string, + }), + periodType: PropTypes.string, } export default PeriodSelect 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', diff --git a/src/util/periods.js b/src/util/periods.js index 366633e8a..777ab1bd6 100644 --- a/src/util/periods.js +++ b/src/util/periods.js @@ -6,23 +6,41 @@ import { periodTypes, periodGroups } from '../constants/periods.js' const getYearOffsetFromNow = (year) => year - new Date(Date.now()).getFullYear() +const filterPeriods = (periods, firstDate, lastDate) => + periods.filter( + (p) => + (!firstDate || p.startDate >= firstDate) && + (!lastDate || p.endDate <= lastDate) + ) + export const getPeriodTypes = (hiddenPeriods = []) => periodTypes().filter(({ group }) => !hiddenPeriods.includes(group)) -export const getFixedPeriodsByType = (periodType, year) => { +export const getFixedPeriodsByType = ({ + periodType, + year, + firstDate, + lastDate, +}) => { const period = getFixedPeriodsOptionsById(periodType) const forceDescendingForYearTypes = !!periodType.match(/^FY|YEARLY/) const offset = getYearOffsetFromNow(year) - const periods = period?.getPeriods({ offset, reversePeriods: true }) || null - if (periods && forceDescendingForYearTypes) { - // TODO: the reverse() is a workaround for a bug in the analytics - // getPeriods function that no longer correctly reverses the order - // for YEARLY and FY period types - return periods.reverse() + let periods = period?.getPeriods({ offset, reversePeriods: true }) + + if (!periods) { + return null } - return periods + + if (firstDate || lastDate) { + periods = filterPeriods(periods, firstDate, lastDate) + } + + // TODO: the reverse() is a workaround for a bug in the analytics + // getPeriods function that no longer correctly reverses the order + // for YEARLY and FY period types + return forceDescendingForYearTypes ? periods.reverse() : periods } export const getRelativePeriods = (hiddenPeriods = []) => @@ -46,7 +64,7 @@ export const getPeriodNames = () => ({ }, {}), }) -export const filterFuturePeriods = (periods) => { - const now = new Date(Date.now()) +export const filterFuturePeriods = (periods, date) => { + const now = new Date(date || Date.now()) return periods.filter(({ startDate }) => new Date(startDate) < now) }