From 79fc2d1881ad948890d84a03595e814bc0e6b30b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 4 May 2026 18:13:12 +0200 Subject: [PATCH 1/8] feat: count features without coordinates across all layer types [DHIS2-237, DHIS2-19850] --- i18n/en.pot | 26 +++- src/actions/layerEdit.js | 6 + src/components/dataItem/DataItemStyle.jsx | 29 ++-- .../dataItem/styles/DataItemStyle.module.css | 13 ++ src/components/datatable/useTableData.js | 13 +- src/components/edit/FacilityDialog.jsx | 30 +++- src/components/edit/earthEngine/StyleTab.jsx | 14 ++ src/components/edit/event/EventDialog.jsx | 16 ++ .../edit/event/styles/EventDialog.module.css | 4 - src/components/edit/orgUnit/OrgUnitDialog.jsx | 36 ++++- src/components/edit/shared/Labels.jsx | 4 +- .../shared/styles/BufferRadius.module.css | 7 +- .../shared/styles/GeometryCentroid.module.css | 8 +- .../edit/styles/LayerDialog.module.css | 16 ++ .../edit/thematic/ThematicDialog.jsx | 21 ++- .../trackedEntity/TrackedEntityDialog.jsx | 9 +- .../TrackedEntityRelationshipTypeSelect.jsx | 3 +- src/components/legend/Legend.jsx | 28 ++++ .../legend/styles/Legend.module.css | 6 +- src/components/orgunits/OrgUnitSelect.jsx | 6 +- src/constants/actionTypes.js | 2 + src/loaders/earthEngineLoader.js | 144 +++++++++++------- src/loaders/eventLoader.js | 19 ++- src/loaders/facilityLoader.js | 118 +++++++++----- src/loaders/orgUnitLoader.js | 138 +++++++++++------ src/loaders/thematicLoader.js | 59 +++++++ src/reducers/layerEdit.js | 6 + src/util/event.js | 9 +- src/util/favorites.js | 14 +- src/util/geojson.js | 23 ++- src/util/orgUnits.js | 137 ++++++++++++++++- src/util/requests.js | 31 ++++ 32 files changed, 793 insertions(+), 202 deletions(-) create mode 100644 src/components/dataItem/styles/DataItemStyle.module.css delete mode 100644 src/components/edit/event/styles/EventDialog.module.css diff --git a/i18n/en.pot b/i18n/en.pot index 3c8b20ac0..8bb40a247 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-05-01T10:18:47.999Z\n" -"PO-Revision-Date: 2026-05-01T10:18:48.000Z\n" +"POT-Creation-Date: 2026-05-04T14:12:54.399Z\n" +"PO-Revision-Date: 2026-05-04T14:12:54.399Z\n" msgid "2020" msgstr "2020" @@ -267,6 +267,9 @@ msgstr "Organisation Units" msgid "Style" msgstr "Style" +msgid "Count org units without coordinates" +msgstr "Count org units without coordinates" + msgid "Point color" msgstr "Point color" @@ -395,6 +398,9 @@ msgstr "View all events" msgid "Radius" msgstr "Radius" +msgid "Count events without coordinates" +msgstr "Count events without coordinates" + msgid "You can style events by data element after selecting a program." msgstr "You can style events by data element after selecting a program." @@ -683,6 +689,19 @@ msgstr "Remove layer" msgid "Filters" msgstr "Filters" +msgid "Data quality" +msgstr "Data quality" + +msgid "{{count}} event without coordinates" +msgid_plural "{{count}} event without coordinates" +msgstr[0] "{{count}} event without coordinates" +msgstr[1] "{{count}} events without coordinates" + +msgid "{{count}} org unit without coordinates" +msgid_plural "{{count}} org unit without coordinates" +msgstr[0] "{{count}} org unit without coordinates" +msgstr[1] "{{count}} org units without coordinates" + msgid "No data found" msgstr "No data found" @@ -1728,6 +1747,9 @@ msgstr "Financial year (Start April)" msgid "Cancelled" msgstr "Cancelled" +msgid "Could not count org units without coordinates" +msgstr "Could not count org units without coordinates" + msgid "Earth Engine layer" msgstr "Earth Engine layer" diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 458f5e557..23a0d13da 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -127,6 +127,12 @@ export const setEventClustering = (checked) => ({ checked, }) +// Set if features without coordinates should be counted and added to data table +export const setCountFeaturesWithoutCoordinates = (checked) => ({ + type: types.LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET, + checked, +}) + // Set event point radius (event layer) export const setEventPointRadius = (radius) => ({ type: types.LAYER_EDIT_EVENT_POINT_RADIUS_SET, diff --git a/src/components/dataItem/DataItemStyle.jsx b/src/components/dataItem/DataItemStyle.jsx index 4c8743f65..dc75e11d3 100644 --- a/src/components/dataItem/DataItemStyle.jsx +++ b/src/components/dataItem/DataItemStyle.jsx @@ -15,6 +15,7 @@ import NoDataLegend from '../edit/shared/NoDataLegend.jsx' import UnclassifiedLegend from '../edit/shared/UnclassifiedLegend.jsx' import OptionSetStyle from '../optionSet/OptionSetStyle.jsx' import BooleanStyle from './BooleanStyle.jsx' +import styles from './styles/DataItemStyle.module.css' const DataItemStyle = ({ dataItem, style }) => { const noDataLegend = useSelector((state) => state.layerEdit.noDataLegend) @@ -48,18 +49,22 @@ const DataItemStyle = ({ dataItem, style }) => { {optionSet ? : null} - {hasClassification && ( - dispatch(setUnclassifiedLegend(v))} - /> - )} - dispatch(setNoDataLegend(v))} - /> +
+
+ {hasClassification && ( + dispatch(setUnclassifiedLegend(v))} + /> + )} + dispatch(setNoDataLegend(v))} + /> +
+
) } diff --git a/src/components/dataItem/styles/DataItemStyle.module.css b/src/components/dataItem/styles/DataItemStyle.module.css new file mode 100644 index 000000000..f2a773e18 --- /dev/null +++ b/src/components/dataItem/styles/DataItemStyle.module.css @@ -0,0 +1,13 @@ +.flexColumnFlow { + display: flex; + flex-wrap: wrap; +} + +.flexColumn { + display: flex; + flex-direction: column; + flex: 1 1 0px; + margin: 0; + box-sizing: border-box; + max-width: 340px; +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 52a780230..4debcb3de 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -195,6 +195,7 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { legend, styleDataItem, data, + dataWithoutCoords, dataFilters, headers: layerHeaders, serverCluster, @@ -207,25 +208,29 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { return null } - if (!data?.length) { + const allData = dataWithoutCoords?.length + ? [...(data || []), ...dataWithoutCoords] + : data + + if (!allData?.length) { errorCode.current = ERROR_NO_VALID_DATA return null } if (layerType === GEOJSON_URL_LAYER) { - return data.map((d) => ({ + return allData.map((d) => ({ ...d.properties, })) } - return data + return allData .filter((d) => !d.properties.hasAdditionalGeometry) .map((d, index) => ({ ...(d.properties || d), ...aggregations[d.id], index, })) - }, [data, aggregations, serverCluster, layerType]) + }, [data, dataWithoutCoords, aggregations, serverCluster, layerType]) const headers = useMemo(() => { if (errorCode.current) { diff --git a/src/components/edit/FacilityDialog.jsx b/src/components/edit/FacilityDialog.jsx index 25bf1942c..ba1f8b5d6 100644 --- a/src/components/edit/FacilityDialog.jsx +++ b/src/components/edit/FacilityDialog.jsx @@ -1,13 +1,15 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' +import cx from 'classnames' import PropTypes from 'prop-types' import React, { useState, useEffect, useCallback } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { setOrgUnits, setRadiusLow, setOrganisationUnitGroupSet, setOrganisationUnitColor, + setCountFeaturesWithoutCoordinates, } from '../../actions/layerEdit.js' import { ORG_UNIT_COLOR, @@ -19,7 +21,7 @@ import { NONE, } from '../../constants/layers.js' import { getOrgUnitsFromRows } from '../../util/analytics.js' -import { Tab, Tabs, NumberField, ColorPicker } from '../core/index.js' +import { Tab, Tabs, NumberField, ColorPicker, Checkbox } from '../core/index.js' import StyleByGroupSet from '../groupSet/StyleByGroupSet.jsx' import OrgUnitSelect from '../orgunits/OrgUnitSelect.jsx' import BufferRadius from './shared/BufferRadius.jsx' @@ -49,6 +51,9 @@ const FacilityDialog = ({ const [orgUnitsError, setOrgUnitsError] = useState() const { data } = useDataQuery(QUERY) const dispatch = useDispatch() + const countFeaturesWithoutCoordinates = useSelector( + (state) => state.layerEdit.countFeaturesWithoutCoordinates + ) const facilityOrgUnitLevel = data?.configuration.facilityOrgUnitLevel const facilityOrgUnitGroupSet = data?.configuration.facilityOrgUnitGroupSet @@ -110,10 +115,24 @@ const FacilityDialog = ({ data-test="facilitydialog-styletab" >
- + + + dispatch( + setCountFeaturesWithoutCoordinates( + checked + ) + ) + } />
@@ -133,7 +152,10 @@ const FacilityDialog = ({ setOrganisationUnitColor(val) ) } - className={styles.narrowField} + className={cx( + styles.narrowField, + styles.marginTop + )} /> { + const dispatch = useDispatch() + const countFeaturesWithoutCoordinates = useSelector( + (state) => state.layerEdit.countFeaturesWithoutCoordinates + ) const { min, max, palette } = style const isClassStyle = min !== undefined && @@ -25,6 +32,13 @@ const StyleTab = ({ unit, style, showBelowMin, hasOrgUnitField }) => { hasOrgUnitField={hasOrgUnitField} forceShowNumberField={true} /> + + dispatch(setCountFeaturesWithoutCoordinates(checked)) + } + />
{isClassStyle && ( diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 48690bc83..084c612aa 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -17,6 +17,7 @@ import { setEndDate, setBackupPeriodsDates, setOrgUnits, + setCountFeaturesWithoutCoordinates, } from '../../../actions/layerEdit.js' import { EVENT_COLOR, @@ -42,6 +43,7 @@ import { NumberField, ImageSelect, ColorPicker, + Checkbox, } from '../../core/index.js' import CoordinateField from '../../dataItem/CoordinateField.jsx' import FilterGroup from '../../dataItem/filter/FilterGroup.jsx' @@ -59,6 +61,7 @@ import EventStatusSelect from './EventStatusSelect.jsx' class EventDialog extends Component { static propTypes = { setBackupPeriodsDates: PropTypes.func.isRequired, + setCountFeaturesWithoutCoordinates: PropTypes.func.isRequired, setEndDate: PropTypes.func.isRequired, setEventClustering: PropTypes.func.isRequired, setEventCoordinateField: PropTypes.func.isRequired, @@ -75,6 +78,7 @@ class EventDialog extends Component { onLayerValidation: PropTypes.func.isRequired, backupPeriodsDates: PropTypes.object, columns: PropTypes.array, + countFeaturesWithoutCoordinates: PropTypes.bool, endDate: PropTypes.string, eventClustering: PropTypes.bool, eventCoordinateField: PropTypes.string, @@ -207,6 +211,7 @@ class EventDialog extends Component { const { // layer options columns = [], + countFeaturesWithoutCoordinates, eventClustering, eventStatus, eventCoordinateField, @@ -232,6 +237,7 @@ class EventDialog extends Component { setEventPointRadius, // setFallbackCoordinateField, setPeriod, + setCountFeaturesWithoutCoordinates, } = this.props const { @@ -405,6 +411,15 @@ class EventDialog extends Component { disabled={eventClustering} defaultRadius={EVENT_BUFFER} /> +
{program ? ( @@ -534,6 +549,7 @@ export default connect( setStartDate, setEndDate, setOrgUnits, + setCountFeaturesWithoutCoordinates, }, null, { diff --git a/src/components/edit/event/styles/EventDialog.module.css b/src/components/edit/event/styles/EventDialog.module.css deleted file mode 100644 index 37df09c18..000000000 --- a/src/components/edit/event/styles/EventDialog.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.text { - padding-top: var(--spacers-dp8); - line-height: 22px; -} diff --git a/src/components/edit/orgUnit/OrgUnitDialog.jsx b/src/components/edit/orgUnit/OrgUnitDialog.jsx index 9ceae1ff5..d067a2298 100644 --- a/src/components/edit/orgUnit/OrgUnitDialog.jsx +++ b/src/components/edit/orgUnit/OrgUnitDialog.jsx @@ -1,10 +1,12 @@ import i18n from '@dhis2/d2-i18n' +import cx from 'classnames' import PropTypes from 'prop-types' import React, { Component } from 'react' import { connect } from 'react-redux' import { setRadiusLow, setOrganisationUnitColor, + setCountFeaturesWithoutCoordinates, } from '../../../actions/layerEdit.js' import { ORG_UNIT_COLOR, @@ -14,7 +16,13 @@ import { MAX_RADIUS, } from '../../../constants/layers.js' import { getOrgUnitsFromRows } from '../../../util/analytics.js' -import { Tab, Tabs, NumberField, ColorPicker } from '../../core/index.js' +import { + Tab, + Tabs, + NumberField, + ColorPicker, + Checkbox, +} from '../../core/index.js' import StyleByGroupSet from '../../groupSet/StyleByGroupSet.jsx' import OrgUnitSelect from '../../orgunits/OrgUnitSelect.jsx' import Labels from '../shared/Labels.jsx' @@ -22,10 +30,12 @@ import styles from '../styles/LayerDialog.module.css' class OrgUnitDialog extends Component { static propTypes = { + setCountFeaturesWithoutCoordinates: PropTypes.func.isRequired, setOrganisationUnitColor: PropTypes.func.isRequired, setRadiusLow: PropTypes.func.isRequired, validateLayer: PropTypes.bool.isRequired, onLayerValidation: PropTypes.func.isRequired, + countFeaturesWithoutCoordinates: PropTypes.bool, organisationUnitColor: PropTypes.string, radiusLow: PropTypes.number, rows: PropTypes.array, @@ -47,8 +57,10 @@ class OrgUnitDialog extends Component { const { radiusLow, organisationUnitColor, + countFeaturesWithoutCoordinates, setOrganisationUnitColor, setRadiusLow, + setCountFeaturesWithoutCoordinates, } = this.props const { tab, orgUnitsError } = this.state @@ -69,14 +81,17 @@ class OrgUnitDialog extends Component { data-test="orgunitdialog-styletab" >
- + +
({ + countFeaturesWithoutCoordinates: + state.layerEdit.countFeaturesWithoutCoordinates, + }), { setRadiusLow, setOrganisationUnitColor, + setCountFeaturesWithoutCoordinates, }, null, { diff --git a/src/components/edit/shared/Labels.jsx b/src/components/edit/shared/Labels.jsx index 7f0854549..c7a4efc50 100644 --- a/src/components/edit/shared/Labels.jsx +++ b/src/components/edit/shared/Labels.jsx @@ -16,6 +16,7 @@ import { Checkbox, FontStyle, LabelDisplayOptions } from '../../core/index.js' import styles from '../styles/LayerDialog.module.css' const Labels = ({ + className, includeDisplayOption, labels, labelTemplate, @@ -37,7 +38,7 @@ const Labels = ({ }, [labels, includeDisplayOption, labelTemplate, setLabelTemplate]) return ( -
+
span > div { - margin: var(--spacers-dp8); } .numberField { diff --git a/src/components/edit/shared/styles/GeometryCentroid.module.css b/src/components/edit/shared/styles/GeometryCentroid.module.css index b7e383ef4..22edf593b 100644 --- a/src/components/edit/shared/styles/GeometryCentroid.module.css +++ b/src/components/edit/shared/styles/GeometryCentroid.module.css @@ -1,15 +1,11 @@ .centroid { - margin: 0 -8px; + margin-top: var(--spacers-dp8); clear: both; align-items: center; } -.centroid > span > div { - margin: var(--spacers-dp8); -} - .notice { - margin: var(--spacers-dp16) 0; + margin: var(--spacers-dp12) 0 var(--spacers-dp4); } .noticeCompact { diff --git a/src/components/edit/styles/LayerDialog.module.css b/src/components/edit/styles/LayerDialog.module.css index 90fad90ba..6745840e8 100644 --- a/src/components/edit/styles/LayerDialog.module.css +++ b/src/components/edit/styles/LayerDialog.module.css @@ -7,6 +7,14 @@ max-width: 340px; } +.marginTop { + margin-top: var(--spacers-dp12) !important; +} + +.noMarginTop { + margin-top: 0 !important; +} + .periodSelect { width: auto; max-width: 360px; @@ -70,6 +78,10 @@ font-weight: bold; } +.sectionHeader { + margin-top: var(--spacers-dp48); +} + .paragraph { padding: var(--spacers-dp8) 0 var(--spacers-dp4); font-size: 13px; @@ -135,6 +147,10 @@ margin: var(--spacers-dp8) 0 0 calc(var(--spacers-dp8) * -1); } +.labels { + margin-top: var(--spacers-dp8); +} + .labelDisplayOptions { margin-left: var(--spacers-dp24); } diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 78d54f329..9f1a4dc4a 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -4,13 +4,14 @@ import { SegmentedControl, IconErrorFilled24 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useState, useMemo, useCallback, useEffect } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { setClassification, setDataItem, setLegendSet, setNoDataLegend, setUnclassifiedLegend, + setCountFeaturesWithoutCoordinates, setPeriods, setStartDate, setEndDate, @@ -38,7 +39,7 @@ import { getDimensionsFromFilters, } from '../../../util/analytics.js' import NumericLegendStyle from '../../classification/NumericLegendStyle.jsx' -import { Tab, Tabs } from '../../core/index.js' +import { Tab, Tabs, Checkbox } from '../../core/index.js' import DimensionFilter from '../../dimensions/DimensionFilter.jsx' import OrgUnitSelect from '../../orgunits/OrgUnitSelect.jsx' import RenderingStrategy from '../../periods/RenderingStrategy.jsx' @@ -81,6 +82,9 @@ const ThematicDialog = ({ legendIsolated, }) => { const dispatch = useDispatch() + const countFeaturesWithoutCoordinates = useSelector( + (state) => state.layerEdit.countFeaturesWithoutCoordinates + ) const { defaultRenderingStrategy, shouldSyncFromOtherLayers, @@ -575,6 +579,19 @@ const ThematicDialog = ({
+ + dispatch( + setCountFeaturesWithoutCoordinates( + checked + ) + ) + } + />
{this.state.showRelationshipsChecked && ( @@ -352,7 +352,12 @@ class TrackedEntityDialog extends Component { {relationshipType ? ( -
+
{i18n.t('Related entity style')}:
{i18n.t( diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 252c42409..1e5d995e2 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -20,6 +20,8 @@ const Legend = ({ source, sourceUrl, decimalPlaces, + eventsWithoutCoordinatesCount, + orgUnitsWithoutCoordinatesCount, isPlugin = false, }) => { const showRange = Array.isArray(items) && !legendNamesContainRange(items) @@ -92,6 +94,30 @@ const Legend = ({ ))}
)} + {(typeof eventsWithoutCoordinatesCount === 'number' || + typeof orgUnitsWithoutCoordinatesCount === 'number') && ( +
+
{i18n.t('Data quality')}:
+ {typeof eventsWithoutCoordinatesCount === 'number' && ( +
+ {i18n.t('{{count}} event without coordinates', { + count: eventsWithoutCoordinatesCount, + defaultValue_plural: + '{{count}} events without coordinates', + })} +
+ )} + {typeof orgUnitsWithoutCoordinatesCount === 'number' && ( +
+ {i18n.t('{{count}} org unit without coordinates', { + count: orgUnitsWithoutCoordinatesCount, + defaultValue_plural: + '{{count}} org units without coordinates', + })} +
+ )} +
+ )} {source && (
{i18n.t('Source')}:  @@ -117,11 +143,13 @@ Legend.propTypes = { coordinateFields: PropTypes.array, decimalPlaces: PropTypes.number, description: PropTypes.string, + eventsWithoutCoordinatesCount: PropTypes.number, explanation: PropTypes.array, filters: PropTypes.array, groups: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), isPlugin: PropTypes.bool, items: PropTypes.array, + orgUnitsWithoutCoordinatesCount: PropTypes.number, source: PropTypes.string, sourceUrl: PropTypes.string, unit: PropTypes.string, diff --git a/src/components/legend/styles/Legend.module.css b/src/components/legend/styles/Legend.module.css index cf80b8027..3f8fdd7c6 100644 --- a/src/components/legend/styles/Legend.module.css +++ b/src/components/legend/styles/Legend.module.css @@ -21,13 +21,15 @@ } .filters, -.coordinateFields { +.coordinateFields, +.dataQuality { padding-top: var(--spacers-dp16); font-size: 12px; } .filters > div:first-child, -.coordinateFields > div:first-child { +.coordinateFields > div:first-child, +.dataQuality > div:first-child { font-weight: bold; } diff --git a/src/components/orgunits/OrgUnitSelect.jsx b/src/components/orgunits/OrgUnitSelect.jsx index 5d56b135a..2be92ee6b 100644 --- a/src/components/orgunits/OrgUnitSelect.jsx +++ b/src/components/orgunits/OrgUnitSelect.jsx @@ -2,7 +2,7 @@ import { OrgUnitDimension } from '@dhis2/analytics' import { CenteredContent, CircularLoader, Help } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useCallback } from 'react' +import React, { useCallback, useMemo } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setOrgUnits } from '../../actions/layerEdit.js' import { translateOrgUnitLevels } from '../../util/orgUnits.js' @@ -40,6 +40,8 @@ const OrgUnitSelect = ({ [dispatch] ) + const rootIds = useMemo(() => roots?.map((r) => r.id) ?? [], [roots]) + const orgUnits = translateOrgUnitLevels( rows?.find((r) => r.dimension === 'ou'), levels @@ -79,7 +81,7 @@ const OrgUnitSelect = ({ })} > r.id)} + roots={rootIds} selected={orgUnits} onSelect={setOrgUnitItems} hideUserOrgUnits={hideUserOrgUnits} diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index c09e25f77..71a2a1677 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -147,6 +147,8 @@ export const LAYER_EDIT_FOLLOW_UP_SET = 'LAYER_EDIT_FOLLOW_UP_SET' export const LAYER_EDIT_NO_DATA_LEGEND_SET = 'LAYER_EDIT_NO_DATA_LEGEND_SET' export const LAYER_EDIT_UNCLASSIFIED_LEGEND_SET = 'LAYER_EDIT_UNCLASSIFIED_LEGEND_SET' +export const LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET = + 'LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET' export const LAYER_EDIT_BAND_SET = 'LAYER_EDIT_BAND_SET' export const LAYER_EDIT_FEATURE_STYLE_SET = 'LAYER_EDIT_FEATURE_STYLE_SET' export const LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET = diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 32690d5dc..ad16a0564 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -4,9 +4,11 @@ import { WARNING_NO_OU_COORD, WARNING_NO_GEOMETRY_COORD, ERROR_CRITICAL, + CUSTOM_ALERT, } from '../constants/alerts.js' import { getEarthEngineLayer } from '../constants/earthEngineLayers/index.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { hasClasses, getStaticFilterFromPeriod, @@ -18,6 +20,7 @@ import { getRoundToPrecisionFn, formatWithSeparator } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, + getOrgUnitsWithoutCoordsCount, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' @@ -28,16 +31,74 @@ const earthEngineLoader = async ({ keyAnalysisDigitGroupSeparator, userId, }) => { - const { format, rows, aggregationType } = config + const { format, rows, aggregationType, countFeaturesWithoutCoordinates } = + config const orgUnits = getOrgUnitsFromRows(rows) const coordinateField = getCoordinateField(config) let loadError const alerts = [] - let layerConfig = {} + // Config parsing + // ----- + + const layerConfig = parseJsonConfig(config.config) let dataset + + if (typeof config.config === 'string') { + // From database as favorite + if (layerConfig.image) { + // Backward compability for layers with periods saved before 2.36 + const filter = layerConfig.filter?.[0] + + if (filter) { + const id = filter.arguments?.[1] + const name = String(layerConfig.image) + const year = + typeof id === 'string' && id.length > 4 + ? parseInt(id.substring(0, 4), 10) + : undefined + + layerConfig.period = { id, name, year } + + delete layerConfig.filter + } + delete layerConfig.image + } else if (layerConfig.filter) { + // Backward compability for layers saved before v100.6.0 + layerConfig.period = getPeriodFromFilter(filter, layerConfig.id) + delete layerConfig.filter + } + + if (layerConfig.params) { + // Backward compability for layers saved before v100.6.0 + layerConfig.style = layerConfig.params + if (typeof layerConfig.params.palette === 'string') { + layerConfig.style.palette = + layerConfig.params.palette.split(',') + } + delete layerConfig.params + } + + dataset = getEarthEngineLayer(layerConfig.id) + + if (dataset) { + delete layerConfig.id + } + + delete config.config + // Remove the always empty filters array from saved map layer object + // so as not to overwrite the filters array from the layer config + delete config.filters + } else { + dataset = getEarthEngineLayer(layerConfig.id) + } + + // Data loading + // ----- + let features + let orgUnitsWithoutCoordsCount = null if (orgUnits && orgUnits.length) { const orgUnitIds = orgUnits.map((item) => item.id) @@ -57,6 +118,29 @@ const earthEngineLoader = async ({ ? toGeoJson(geoFeatureData.geoFeatures) : null + if (countFeaturesWithoutCoordinates) { + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features: mainFeatures || [], + }) + if (result.error) { + alerts.push({ + warning: true, + code: CUSTOM_ALERT, + message: i18n.t( + 'Could not count org units without coordinates' + ), + }) + } else { + orgUnitsWithoutCoordsCount = result.count + if (result.count > 0) { + config.dataWithoutCoords = result.missingOrgUnits + } + } + } + if (coordinateField) { const coordFieldData = await engine.query(GEOFEATURES_QUERY, { variables: { @@ -99,56 +183,8 @@ const earthEngineLoader = async ({ } } - if (typeof config.config === 'string') { - // From database as favorite - layerConfig = JSON.parse(config.config) - - if (layerConfig.image) { - // Backward compability for layers with periods saved before 2.36 - const filter = layerConfig.filter?.[0] - - if (filter) { - const id = filter.arguments?.[1] - const name = String(layerConfig.image) - const year = - typeof id === 'string' && id.length > 4 - ? parseInt(id.substring(0, 4), 10) - : undefined - - layerConfig.period = { id, name, year } - - delete layerConfig.filter - } - delete layerConfig.image - } else if (layerConfig.filter) { - // Backward compability for layers saved before v100.6.0 - layerConfig.period = getPeriodFromFilter(filter, layerConfig.id) - delete layerConfig.filter - } - - if (layerConfig.params) { - // Backward compability for layers saved before v100.6.0 - layerConfig.style = layerConfig.params - if (typeof layerConfig.params.palette === 'string') { - layerConfig.style.palette = - layerConfig.params.palette.split(',') - } - delete layerConfig.params - } - - dataset = getEarthEngineLayer(layerConfig.id) - - if (dataset) { - delete layerConfig.id - } - - delete config.config - // Remove the always empty filters array from saved map layer object - // so as not to overwrite the filters array from the layer config - delete config.filters - } else { - dataset = getEarthEngineLayer(layerConfig.id) - } + // Legend + // ----- const layer = { ...dataset, @@ -209,6 +245,10 @@ const earthEngineLoader = async ({ ) } + if (typeof orgUnitsWithoutCoordsCount === 'number') { + legend.orgUnitsWithoutCoordinatesCount = orgUnitsWithoutCoordsCount + } + const filter = getStaticFilterFromPeriod(period, filters) return { diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 81c75b2ab..c92f8795d 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -25,7 +25,8 @@ import { formatStartEndDate, getDateArray } from '../util/time.js' import { isValidUid } from '../util/uid.js' // Server clustering if more than 2000 events -const shouldUseServerCluster = (count) => count > EVENT_SERVER_CLUSTER_COUNT +const shouldUseServerCluster = (count, countFeaturesWithoutCoordinates) => + !countFeaturesWithoutCoordinates && count > EVENT_SERVER_CLUSTER_COUNT const accessDeniedAlert = { warning: true, @@ -102,11 +103,15 @@ const loadEventLayer = async ({ loadExtended, }) => { const { + countFeaturesWithoutCoordinates, legendDecimalPlaces, legendIsolated, unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, } = parseJsonConfig(config.config) + if (countFeaturesWithoutCoordinates) { + config.countFeaturesWithoutCoordinates = true + } if (legendDecimalPlaces !== undefined) { config.legendDecimalPlaces = legendDecimalPlaces } @@ -185,13 +190,16 @@ const loadEventLayer = async ({ if (eventClustering && !styleDataItem) { const response = await analyticsEngine.events.getCount(analyticsRequest) config.bounds = getBounds(response.extent) - config.serverCluster = shouldUseServerCluster(response.count) + config.serverCluster = shouldUseServerCluster( + response.count, + config.countFeaturesWithoutCoordinates + ) serverCount = response.count } if (!config.serverCluster) { config.outputIdScheme = 'ID' // Required for StyleByDataItem to work - const { data, response } = await loadData({ + const { data, response, dataWithoutCoords } = await loadData({ request: analyticsRequest, config, analyticsEngine, @@ -199,6 +207,11 @@ const loadEventLayer = async ({ const { total } = response.metaData.pager config.data = data + if (config.countFeaturesWithoutCoordinates) { + config.dataWithoutCoords = dataWithoutCoords + config.legend.eventsWithoutCoordinatesCount = + dataWithoutCoords.length + } if (styleDataItem) { await styleByDataItem(config, engine) diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index 93b6e3a57..2c4e3070c 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -5,14 +5,17 @@ import { CUSTOM_ALERT, } from '../constants/alerts.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' import { - ORG_UNITS_GROUP_SET_QUERY, getPointItems, getPolygonItems, getStyledOrgUnits, getCoordinateField, - parseGroupSet, + getOrgUnitsWithoutCoordsCount, + addGroupCountsToLegend, + fetchAndParseGroupSet, + fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' @@ -24,18 +27,26 @@ const facilityLoader = async ({ baseUrl, }) => { const { rows, organisationUnitGroupSet: groupSet, areaRadius } = config - - const orgUnits = getOrgUnitsFromRows(rows) const includeGroupSets = !!groupSet + const orgUnits = getOrgUnitsFromRows(rows) + const orgUnitIds = orgUnits.map((item) => item.id) const coordinateField = getCoordinateField(config) + const name = i18n.t('Facilities') let loadError const alerts = [] - const orgUnitIds = orgUnits.map((item) => item.id) - let associatedGeometries + // Config parsing + // ----- - const name = i18n.t('Facilities') + const { countFeaturesWithoutCoordinates } = parseJsonConfig(config.config) + if (countFeaturesWithoutCoordinates) { + config.countFeaturesWithoutCoordinates = true + } + delete config.config + + // Data loading + // ----- let data = {} try { @@ -68,61 +79,85 @@ const facilityLoader = async ({ }) } - const features = - data?.geoFeatures && toGeoJson(getPointItems(data.geoFeatures)) + const features = data?.geoFeatures + ? toGeoJson(getPointItems(data.geoFeatures)) + : [] - // Load organisationUnitGroups if not passed - let orgUnitGroups if (includeGroupSets && !groupSet.organisationUnitGroups) { - try { - orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { - variables: { - id: groupSet?.id, - }, - }) - } catch (err) { + const parsedGroupSet = await fetchAndParseGroupSet(engine, groupSet) + if (parsedGroupSet) { + groupSet.organisationUnitGroups = + parsedGroupSet.organisationUnitGroups + groupSet.name = parsedGroupSet.name + } else { loadError = i18n.t('GroupSet used for styling was not found') } } - if (orgUnitGroups) { - const { groupSets } = orgUnitGroups - groupSet.organisationUnitGroups = parseGroupSet({ - organisationUnitGroups: groupSets.organisationUnitGroups, - }) - groupSet.name = groupSets.name + let associatedGeometries + if (coordinateField) { + associatedGeometries = await fetchAssociatedGeometries( + engine, + { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField, + userId, + }, + getPolygonItems + ) + + if (!associatedGeometries?.length) { + alerts.push({ + code: WARNING_NO_GEOMETRY_COORD, + message: coordinateField.name, + }) + } } + // Styling and Legend + // ----- + const { styledFeatures, legend } = getStyledOrgUnits({ features, groupSet, config, baseUrl, }) - legend.title = name - if (coordinateField) { - const rawData = await engine.query(GEOFEATURES_QUERY, { - variables: { - orgUnitIds, - keyAnalysisDisplayProperty, - includeGroupSets, - coordinateField: coordinateField.id, - userId, - }, - }) + legend.title = name - associatedGeometries = rawData?.geoFeatures - ? toGeoJson(getPolygonItems(rawData.geoFeatures)) - : null + if (groupSet?.id) { + addGroupCountsToLegend(legend.items, styledFeatures, groupSet) + } else if (legend.items[0]) { + legend.items[0].count = styledFeatures.length + } - if (!associatedGeometries.length) { + if (config.countFeaturesWithoutCoordinates) { + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features, + }) + if (result.error) { alerts.push({ - code: WARNING_NO_GEOMETRY_COORD, - message: coordinateField.name, + warning: true, + code: CUSTOM_ALERT, + message: i18n.t( + 'Could not count org units without coordinates' + ), }) + } else { + legend.orgUnitsWithoutCoordinatesCount = result.count + if (result.count > 0) { + config.dataWithoutCoords = result.missingOrgUnits + } } + } + if (coordinateField) { legend.items.push({ name: coordinateField.name, type: 'polygon', @@ -154,6 +189,7 @@ const facilityLoader = async ({ isLoaded: true, isLoading: false, isExpanded: true, + isVisible: config.isVisible ?? true, loadError, } } diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 982eedfad..0fe647329 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -4,15 +4,20 @@ import { WARNING_NO_OU_COORD, WARNING_NO_GEOMETRY_COORD, ERROR_CRITICAL, + CUSTOM_ALERT, } from '../constants/alerts.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' import { - ORG_UNITS_GROUP_SET_QUERY, addAssociatedGeometries, getStyledOrgUnits, getCoordinateField, - parseGroupSet, + getOrgUnitsWithoutCoordsCount, + fetchOrgUnitDetails, + addGroupCountsToLegend, + fetchAndParseGroupSet, + fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' @@ -24,17 +29,26 @@ const orgUnitLoader = async ({ baseUrl, }) => { const { rows, organisationUnitGroupSet: groupSet } = config - - const orgUnits = getOrgUnitsFromRows(rows) const includeGroupSets = !!groupSet + const orgUnits = getOrgUnitsFromRows(rows) + const orgUnitIds = orgUnits.map((item) => item.id) const coordinateField = getCoordinateField(config) + const name = i18n.t('Organisation units') let loadError const alerts = [] - const orgUnitIds = orgUnits.map((item) => item.id) - let associatedGeometries - const name = i18n.t('Organisation units') + // Config parsing + // ----- + + const { countFeaturesWithoutCoordinates } = parseJsonConfig(config.config) + if (countFeaturesWithoutCoordinates) { + config.countFeaturesWithoutCoordinates = true + } + delete config.config + + // Data loading + // ----- const data = await engine.query( GEOFEATURES_QUERY, @@ -58,23 +72,6 @@ const orgUnitLoader = async ({ ) const mainFeatures = data?.geoFeatures ? toGeoJson(data.geoFeatures) : [] - - const orgUnitLevels = await apiFetchOrganisationUnitLevels(engine) - - // Load organisationUnitGroups if not passed - let orgUnitGroups - if (includeGroupSets && !groupSet.organisationUnitGroups) { - try { - orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { - variables: { - id: groupSet?.id, - }, - }) - } catch (err) { - loadError = i18n.t('GroupSet used for styling was not found') - } - } - if (!mainFeatures.length && !alerts.length) { alerts.push({ code: WARNING_NO_OU_COORD, @@ -82,30 +79,30 @@ const orgUnitLoader = async ({ }) } - if (orgUnitGroups) { - const { groupSets } = orgUnitGroups - groupSet.organisationUnitGroups = parseGroupSet({ - organisationUnitGroups: groupSets.organisationUnitGroups, - }) - groupSet.name = groupSets.name + const orgUnitLevels = await apiFetchOrganisationUnitLevels(engine) + + if (includeGroupSets && !groupSet.organisationUnitGroups) { + const parsedGroupSet = await fetchAndParseGroupSet(engine, groupSet) + if (parsedGroupSet) { + groupSet.organisationUnitGroups = + parsedGroupSet.organisationUnitGroups + groupSet.name = parsedGroupSet.name + } else { + loadError = i18n.t('GroupSet used for styling was not found') + } } + let associatedGeometries if (coordinateField) { - const rawData = await engine.query(GEOFEATURES_QUERY, { - variables: { - orgUnitIds, - keyAnalysisDisplayProperty, - includeGroupSets, - coordinateField: coordinateField.id, - userId, - }, + associatedGeometries = await fetchAssociatedGeometries(engine, { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField, + userId, }) - associatedGeometries = rawData?.geoFeatures - ? toGeoJson(rawData.geoFeatures) - : null - - if (!associatedGeometries.length) { + if (!associatedGeometries?.length) { alerts.push({ code: WARNING_NO_GEOMETRY_COORD, message: coordinateField.name, @@ -115,6 +112,9 @@ const orgUnitLoader = async ({ const features = addAssociatedGeometries(mainFeatures, associatedGeometries) + // Styling and Legend + // ----- + const { styledFeatures, legend } = getStyledOrgUnits({ features, groupSet, @@ -131,6 +131,57 @@ const orgUnitLoader = async ({ legend.title = name + if (groupSet?.id) { + addGroupCountsToLegend(legend.items, mainFeatures, groupSet) + } else { + legend.items.forEach((item) => (item.count = 0)) + mainFeatures.forEach((f) => { + const levelInfo = orgUnitLevels.find( + (l) => l.level === f.properties.level + ) + const item = + levelInfo && + legend.items.find((i) => i.name === levelInfo.displayName) + if (item) { + item.count++ + } + }) + } + + if (config.countFeaturesWithoutCoordinates) { + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features: mainFeatures, + }) + if (result.error) { + alerts.push({ + warning: true, + code: CUSTOM_ALERT, + message: i18n.t( + 'Could not count org units without coordinates' + ), + }) + } else { + legend.orgUnitsWithoutCoordinatesCount = result.count + if (result.count > 0) { + const details = await fetchOrgUnitDetails( + engine, + result.missingOrgUnits.map((o) => o.id) + ) + config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({ + ...ou, + properties: { + ...ou.properties, + level: details[ou.id]?.level, + parentName: details[ou.id]?.parentName, + }, + })) + } + } + } + return { ...config, data: styledFeatures, @@ -140,6 +191,7 @@ const orgUnitLoader = async ({ isLoaded: true, isLoading: false, isExpanded: true, + isVisible: config.isVisible ?? true, loadError, } } diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 1751fa31e..b9017113b 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -6,6 +6,7 @@ import { WARNING_NO_OU_COORD, WARNING_NO_GEOMETRY_COORD, ERROR_CRITICAL, + CUSTOM_ALERT, } from '../constants/alerts.js' import { dimConf } from '../constants/dimension.js' import { EVENT_STATUS_COMPLETED } from '../constants/eventStatuses.js' @@ -46,6 +47,8 @@ import { import { getCoordinateField, addAssociatedGeometries, + getOrgUnitsWithoutCoordsCount, + fetchOrgUnitDetails, } from '../util/orgUnits.js' import { LEGEND_SET_QUERY, GEOFEATURES_QUERY } from '../util/requests.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' @@ -76,11 +79,15 @@ const thematicLoader = async ({ const coordinateField = getCoordinateField(config) const { + countFeaturesWithoutCoordinates, legendDecimalPlaces, legendIsolated, unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, } = parseJsonConfig(config.config) + if (countFeaturesWithoutCoordinates) { + config.countFeaturesWithoutCoordinates = true + } if (legendDecimalPlaces !== undefined) { config.legendDecimalPlaces = legendDecimalPlaces } @@ -102,6 +109,9 @@ const thematicLoader = async ({ delete config.noDataColor delete config.config + const orgUnitIds = getOrgUnitsFromRows(config.rows).map((item) => item.id) + let orgUnitsWithoutCoordsCount = null + // Resolve legendSet and method (favorites may have the wrong method) const legendSet = await resolveLegendSet(config, dataItem, engine) const method = legendSet ? CLASSIFICATION_PREDEFINED : config.method @@ -172,6 +182,40 @@ const thematicLoader = async ({ const [mainFeatures, data, associatedGeometries] = response const valueById = getValueById(data) + + if (config.countFeaturesWithoutCoordinates) { + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features: mainFeatures || [], + }) + if (!result.error) { + orgUnitsWithoutCoordsCount = result.count + if (result.count > 0) { + const details = await fetchOrgUnitDetails( + engine, + result.missingOrgUnits.map((o) => o.id) + ) + config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({ + ...ou, + properties: { + ...ou.properties, + level: details[ou.id]?.level, + parentName: details[ou.id]?.parentName, + rawValue: valueById[ou.id], + value: + valueById[ou.id] === undefined + ? undefined + : formatWithSeparator( + valueById[ou.id], + keyAnalysisDigitGroupSeparator + ), + }, + })) + } + } + } const valuesByPeriod = isSingleMap ? null : getValuesByPeriod(data) // [PATH] null → Single; populated → Timeline / Split (do not creates OrgUnits with no data) const names = getApiResponseNames( @@ -220,6 +264,17 @@ const thematicLoader = async ({ }) } + if ( + config.countFeaturesWithoutCoordinates && + typeof orgUnitsWithoutCoordsCount !== 'number' + ) { + alerts.push({ + warning: true, + code: CUSTOM_ALERT, + message: i18n.t('Could not count org units without coordinates'), + }) + } + if (coordinateField && !associatedGeometries.length) { alerts.push({ code: WARNING_NO_GEOMETRY_COORD, @@ -271,6 +326,10 @@ const thematicLoader = async ({ decimalPlaces: config.legendDecimalPlaces, } + if (typeof orgUnitsWithoutCoordsCount === 'number') { + legend.orgUnitsWithoutCoordinatesCount = orgUnitsWithoutCoordsCount + } + if (dimensions && dimensions.length) { legend.filters = dimensions.map( (d) => diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 2d1d72cd7..904b1c8c1 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -370,6 +370,12 @@ const layerEdit = (state = null, action) => { eventClustering: action.checked, } + case types.LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET: + return { + ...state, + countFeaturesWithoutCoordinates: action.checked, + } + case types.LAYER_EDIT_EVENT_POINT_RADIUS_SET: return { ...state, diff --git a/src/util/event.js b/src/util/event.js index 52366960c..222aa5628 100644 --- a/src/util/event.js +++ b/src/util/event.js @@ -72,6 +72,7 @@ export const getAnalyticsRequest = async ( fallbackCoordinateField, relativePeriodDate, isExtended, + countFeaturesWithoutCoordinates, }, { nameProperty, engine, analyticsEngine } ) => { @@ -99,7 +100,7 @@ export const getAnalyticsRequest = async ( let analyticsRequest = new analyticsEngine.request() .withProgram(program.id) .withStage(programStage.id) - .withCoordinatesOnly(true) + .withCoordinatesOnly(!countFeaturesWithoutCoordinates) analyticsRequest = period ? analyticsRequest.addPeriodFilter(period.id) @@ -157,11 +158,15 @@ export const loadData = async ({ request.withPageSize(pageSize) ) // DHIS2-10742 - const { data, names } = createEventFeatures(response, config) + const { data, names, dataWithoutCoords } = createEventFeatures( + response, + config + ) return { data, names, + dataWithoutCoords, response, } } diff --git a/src/util/favorites.js b/src/util/favorites.js index 898408357..1e90f4a73 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -2,7 +2,9 @@ import { isNil, omitBy, pick, isObject, omit } from 'lodash/fp' import { EARTH_ENGINE_LAYER, EVENT_LAYER, + FACILITY_LAYER, GEOJSON_URL_LAYER, + ORG_UNIT_LAYER, THEMATIC_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' @@ -54,6 +56,7 @@ const validLayerProperties = [ 'labelFontWeight', 'labelFontColor', 'labelTemplate', + 'countFeaturesWithoutCoordinates', 'legendDecimalPlaces', 'legendIsolated', 'lastUpdated', @@ -217,7 +220,12 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.relationshipLineColor delete layer.relationshipOutsideProgram delete layer.periodType - } else if (layerType === THEMATIC_LAYER || layerType === EVENT_LAYER) { + } else if ( + layerType === THEMATIC_LAYER || + layerType === EVENT_LAYER || + layerType === ORG_UNIT_LAYER || + layerType === FACILITY_LAYER + ) { if (cleanMapviewConfig) { const configData = {} if (layer.legendDecimalPlaces !== undefined) { @@ -233,6 +241,9 @@ const models2objects = (layer, cleanMapviewConfig) => { layer.noDataColor = layer.noDataLegend.color // noDataColor is the DHIS2 API schema field — store color there for backward compatibility configData.noDataLegend = layer.noDataLegend } + if (layer.countFeaturesWithoutCoordinates) { + configData.countFeaturesWithoutCoordinates = true + } if (Object.keys(configData).length) { layer.config = JSON.stringify(configData) } @@ -242,6 +253,7 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.legendIsolated delete layer.noDataLegend delete layer.unclassifiedLegend + delete layer.countFeaturesWithoutCoordinates } else if (layerType === GEOJSON_URL_LAYER) { if (cleanMapviewConfig) { layer.config = { diff --git a/src/util/geojson.js b/src/util/geojson.js index c5f18ba41..5f1e78168 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -65,7 +65,10 @@ export const createEventFeature = ({ export const buildEventGeometryGetter = (headers) => { const geomCol = findIndex(headers, (h) => h.name === 'geometry') - return (event) => JSON.parse(event[geomCol]) + return (event) => { + const value = event[geomCol] + return value ? JSON.parse(value) : null + } } export const createEventFeatures = (response, config = {}) => { @@ -88,8 +91,11 @@ export const createEventFeatures = (response, config = {}) => { ) const options = Object.values(response.metaData.items) - const data = response.rows.map((row) => - createEventFeature({ + const data = [] + const dataWithoutCoords = [] + + response.rows.forEach((row) => { + const feature = createEventFeature({ headers: response.headers, names: config.outputIdScheme !== 'ID' ? names : {}, options, @@ -98,7 +104,12 @@ export const createEventFeatures = (response, config = {}) => { getGeometry, geometryCentroid: config.geometryCentroid, }) - ) + if (feature.geometry) { + data.push(feature) + } else { + dataWithoutCoords.push(feature) + } + }) // Sort to draw polygons before points data.sort((feature) => @@ -109,7 +120,7 @@ export const createEventFeatures = (response, config = {}) => { : 0 ) - return { data, names } + return { data, names, dataWithoutCoords } } // Include column for data element used for styling (if not already used in filter) @@ -241,7 +252,7 @@ export const buildGeoJsonFeatures = (geoJson) => { const types = [] const featureCollection = finalGeoJson.features.map((f, i) => { - const nonMultiType = f.geometry.type.replace('Multi', '') + const nonMultiType = f.geometry.type.replaceAll('Multi', '') // return list of types in the data (but not Multi* types, // because those should get lumped in with the non-Multi* type for the legend) if (!types.includes(nonMultiType)) { diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 2217db204..e69a3fcf8 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -10,6 +10,13 @@ import { NONE, } from '../constants/layers.js' import { getUniqueColor } from './colors.js' +import { toGeoJson } from './map.js' +import { + FIRST_DATA_ELEMENT_QUERY, + GEOFEATURES_QUERY, + ORG_UNITS_COUNT_QUERY, + ORG_UNIT_DETAILS_QUERY, +} from './requests.js' const getGroupColor = (groups) => { const groupsWithoutColors = groups.filter((g) => !g.color) @@ -81,13 +88,15 @@ export const getOrgUnitGroupLegendItems = ( useColor, contextPath ) => - groups.map(({ name, color = true, symbol }) => + groups.map(({ id, name, color = true, symbol }) => useColor ? { + id, name, color, } : { + id, name, image: `${contextPath}/images/orgunitgroup/${symbol}`, } @@ -191,7 +200,9 @@ export const getStyledOrgUnits = ({ styledFeatures, legend: { unit: name, - items: [...levelItems, ...groupItems, ...facilityItems], + items: groupItems.length + ? groupItems + : [...levelItems, ...facilityItems], }, } } @@ -224,6 +235,128 @@ export const getCoordinateField = ({ orgUnitField, orgUnitFieldDisplayName }) => ? { id: orgUnitField, name: orgUnitFieldDisplayName } : null +export const getOrgUnitsWithoutCoordsCount = async ({ + engine, + orgUnitIds, + userId, + features = [], +}) => { + if (!orgUnitIds.length) { + return { count: 0, missingOrgUnits: [] } + } + + try { + const deResult = await engine.query(FIRST_DATA_ELEMENT_QUERY) + const dataElementId = deResult?.dataElements?.dataElements?.[0]?.id + if (!dataElementId) { + return { error: true } + } + + const countData = await engine.query(ORG_UNITS_COUNT_QUERY, { + variables: { dataElementId, orgUnitIds, userId }, + }) + const allOuIds = + countData?.orgUnitsCount?.metaData?.dimensions?.ou || [] + const metaItems = countData?.orgUnitsCount?.metaData?.items || {} + + const featureIds = new Set(features.map((f) => f.id)) + const missingOrgUnits = allOuIds + .filter((id) => !featureIds.has(id)) + .map((id) => ({ + id, + properties: { id, name: metaItems[id]?.name || id }, + })) + + return { count: missingOrgUnits.length, missingOrgUnits } + } catch { + return { error: true } + } +} + +const OU_DETAILS_BATCH_SIZE = 100 + +export const fetchOrgUnitDetails = async (engine, ids) => { + if (!ids.length) { + return {} + } + + const batches = [] + for (let i = 0; i < ids.length; i += OU_DETAILS_BATCH_SIZE) { + batches.push(ids.slice(i, i + OU_DETAILS_BATCH_SIZE)) + } + + try { + const results = await Promise.all( + batches.map((batch) => + engine.query(ORG_UNIT_DETAILS_QUERY, { + variables: { ids: batch }, + }) + ) + ) + + return results.reduce((acc, result) => { + ;(result.orgUnits.organisationUnits || []).forEach((ou) => { + acc[ou.id] = { level: ou.level, parentName: ou.parent?.name } + }) + return acc + }, {}) + } catch { + return {} + } +} + +export const addGroupCountsToLegend = (legendItems, features, groupSet) => { + legendItems.forEach((item) => (item.count = 0)) + features.forEach((f) => { + const groupId = f.properties?.dimensions?.[groupSet.id] + const item = legendItems.find((i) => i.id === groupId) + if (item) { + item.count++ + } + }) +} + +export const fetchAndParseGroupSet = async (engine, groupSet) => { + try { + const { groupSets } = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { + variables: { id: groupSet?.id }, + }) + return { + organisationUnitGroups: parseGroupSet({ + organisationUnitGroups: groupSets.organisationUnitGroups, + }), + name: groupSets.name, + } + } catch { + return null + } +} + +export const fetchAssociatedGeometries = async ( + engine, + { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField, + userId, + }, + filterGeoFeatures = (items) => items +) => { + const rawData = await engine.query(GEOFEATURES_QUERY, { + variables: { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField: coordinateField.id, + userId, + }, + }) + return rawData?.geoFeatures + ? toGeoJson(filterGeoFeatures(rawData.geoFeatures)) + : null +} + // Combines main org unit features with associated geometries export const addAssociatedGeometries = (mainFeatures, associatedGeometries) => { // Return main features if there are no associated geomteries diff --git a/src/util/requests.js b/src/util/requests.js index e569b00b5..b97840cf5 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -143,3 +143,34 @@ export const GEOFEATURES_QUERY = { }), }, } + +export const FIRST_DATA_ELEMENT_QUERY = { + dataElements: { + resource: 'dataElements', + params: { pageSize: 1, fields: 'id' }, + }, +} + +export const ORG_UNITS_COUNT_QUERY = { + orgUnitsCount: { + resource: 'analytics', + params: ({ dataElementId, orgUnitIds, userId }) => ({ + dimension: `dx:${dataElementId},ou:${orgUnitIds.join(';')}`, + filter: 'pe:THIS_YEAR', + skipData: true, + skipMeta: false, + _: userId, + }), + }, +} + +export const ORG_UNIT_DETAILS_QUERY = { + orgUnits: { + resource: 'organisationUnits', + params: ({ ids }) => ({ + filter: `id:in:[${ids.join(',')}]`, + fields: 'id,level,parent[displayName~rename(name)]', + paging: false, + }), + }, +} From 99a645c7eb7bbf499e8928132a2dbd87cde9ad3a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 4 May 2026 23:12:40 +0200 Subject: [PATCH 2/8] fix: guard against null layerEdit in connected component selectors --- src/components/classification/Classification.jsx | 8 ++++---- src/components/classification/IsolatedClass.jsx | 2 +- src/components/classification/NumericLegendStyle.jsx | 2 +- src/components/classification/SingleColor.jsx | 4 ++-- .../edit/earthEngine/AggregationSelect.jsx | 6 +++--- src/components/edit/earthEngine/BandSelect.jsx | 4 ++-- src/components/edit/shared/BufferRadius.jsx | 2 +- src/components/edit/shared/GeometryCentroid.jsx | 4 ++-- src/components/edit/shared/Labels.jsx | 12 ++++++------ .../edit/thematic/AggregationTypeSelect.jsx | 2 +- .../edit/thematic/CompletedOnlyCheckbox.jsx | 2 +- src/components/edit/thematic/RadiusSelect.jsx | 4 ++-- src/components/groupSet/StyleByGroupSet.jsx | 2 +- 13 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/components/classification/Classification.jsx b/src/components/classification/Classification.jsx index 1c1f50e68..e71bcf043 100644 --- a/src/components/classification/Classification.jsx +++ b/src/components/classification/Classification.jsx @@ -91,10 +91,10 @@ Classification.propTypes = { export default connect( ({ layerEdit }) => ({ - method: layerEdit.method, - classes: layerEdit.classes, - colorScale: layerEdit.colorScale, - legendDecimalPlaces: layerEdit.legendDecimalPlaces, + method: layerEdit?.method, + classes: layerEdit?.classes, + colorScale: layerEdit?.colorScale, + legendDecimalPlaces: layerEdit?.legendDecimalPlaces, }), { setClassification, setColorScale, setLegendDecimalPlaces } )(Classification) diff --git a/src/components/classification/IsolatedClass.jsx b/src/components/classification/IsolatedClass.jsx index 0ce1b5bf9..f2eab67f8 100644 --- a/src/components/classification/IsolatedClass.jsx +++ b/src/components/classification/IsolatedClass.jsx @@ -99,6 +99,6 @@ IsolatedClass.propTypes = { } export default connect( - ({ layerEdit }) => ({ legendIsolated: layerEdit.legendIsolated }), + ({ layerEdit }) => ({ legendIsolated: layerEdit?.legendIsolated }), { setLegendIsolated } )(IsolatedClass) diff --git a/src/components/classification/NumericLegendStyle.jsx b/src/components/classification/NumericLegendStyle.jsx index eae056faa..a8634fec0 100644 --- a/src/components/classification/NumericLegendStyle.jsx +++ b/src/components/classification/NumericLegendStyle.jsx @@ -74,7 +74,7 @@ NumericLegendStyle.propTypes = { export default connect( ({ layerEdit }) => ({ - method: layerEdit.method, + method: layerEdit?.method, }), { setClassification } )(NumericLegendStyle) diff --git a/src/components/classification/SingleColor.jsx b/src/components/classification/SingleColor.jsx index 980314acc..31729172e 100644 --- a/src/components/classification/SingleColor.jsx +++ b/src/components/classification/SingleColor.jsx @@ -51,8 +51,8 @@ SingleColor.propTypes = { export default connect( ({ layerEdit }) => ({ - color: layerEdit.colorScale, - legendDecimalPlaces: layerEdit.legendDecimalPlaces, + color: layerEdit?.colorScale, + legendDecimalPlaces: layerEdit?.legendDecimalPlaces, }), { setColorScale, setLegendDecimalPlaces } )(SingleColor) diff --git a/src/components/edit/earthEngine/AggregationSelect.jsx b/src/components/edit/earthEngine/AggregationSelect.jsx index 79ea8af87..fb1b66660 100644 --- a/src/components/edit/earthEngine/AggregationSelect.jsx +++ b/src/components/edit/earthEngine/AggregationSelect.jsx @@ -51,9 +51,9 @@ AggregationSelect.propTypes = { export default connect( ({ layerEdit }) => ({ - aggregations: layerEdit.aggregations, - defaultAggregations: layerEdit.defaultAggregations, - aggregationType: layerEdit.aggregationType, + aggregations: layerEdit?.aggregations, + defaultAggregations: layerEdit?.defaultAggregations, + aggregationType: layerEdit?.aggregationType, }), { setAggregationType } )(AggregationSelect) diff --git a/src/components/edit/earthEngine/BandSelect.jsx b/src/components/edit/earthEngine/BandSelect.jsx index 9a41de28e..9fd1df037 100644 --- a/src/components/edit/earthEngine/BandSelect.jsx +++ b/src/components/edit/earthEngine/BandSelect.jsx @@ -29,8 +29,8 @@ BandSelect.propTypes = { export default connect( ({ layerEdit }) => ({ - band: layerEdit.band, - bands: layerEdit.bands, + band: layerEdit?.band, + bands: layerEdit?.bands, }), { setBand } )(BandSelect) diff --git a/src/components/edit/shared/BufferRadius.jsx b/src/components/edit/shared/BufferRadius.jsx index e440cbce8..c2e74bf22 100644 --- a/src/components/edit/shared/BufferRadius.jsx +++ b/src/components/edit/shared/BufferRadius.jsx @@ -93,7 +93,7 @@ BufferRadius.propTypes = { export default connect( ({ layerEdit }) => ({ - radius: layerEdit.areaRadius, + radius: layerEdit?.areaRadius, }), { setBufferRadius } )(BufferRadius) diff --git a/src/components/edit/shared/GeometryCentroid.jsx b/src/components/edit/shared/GeometryCentroid.jsx index 50da2bea5..98f179d7c 100644 --- a/src/components/edit/shared/GeometryCentroid.jsx +++ b/src/components/edit/shared/GeometryCentroid.jsx @@ -73,8 +73,8 @@ GeometryCentroid.propTypes = { export default connect( ({ layerEdit }) => ({ - geometryCentroid: layerEdit.geometryCentroid, - eventCoordinateFieldType: layerEdit.eventCoordinateFieldType, + geometryCentroid: layerEdit?.geometryCentroid, + eventCoordinateFieldType: layerEdit?.eventCoordinateFieldType, }), { setGeometryCentroid } )(GeometryCentroid) diff --git a/src/components/edit/shared/Labels.jsx b/src/components/edit/shared/Labels.jsx index c7a4efc50..d894fd916 100644 --- a/src/components/edit/shared/Labels.jsx +++ b/src/components/edit/shared/Labels.jsx @@ -91,12 +91,12 @@ Labels.propTypes = { export default connect( ({ layerEdit }) => ({ - labels: layerEdit.labels, - labelTemplate: layerEdit.labelTemplate, - labelFontColor: layerEdit.labelFontColor, - labelFontSize: layerEdit.labelFontSize, - labelFontStyle: layerEdit.labelFontStyle, - labelFontWeight: layerEdit.labelFontWeight, + labels: layerEdit?.labels, + labelTemplate: layerEdit?.labelTemplate, + labelFontColor: layerEdit?.labelFontColor, + labelFontSize: layerEdit?.labelFontSize, + labelFontStyle: layerEdit?.labelFontStyle, + labelFontWeight: layerEdit?.labelFontWeight, }), { setLabels, diff --git a/src/components/edit/thematic/AggregationTypeSelect.jsx b/src/components/edit/thematic/AggregationTypeSelect.jsx index f364140b0..b312fee36 100644 --- a/src/components/edit/thematic/AggregationTypeSelect.jsx +++ b/src/components/edit/thematic/AggregationTypeSelect.jsx @@ -32,7 +32,7 @@ AggregationTypeSelect.propTypes = { export default connect( ({ layerEdit }) => ({ - aggregationType: layerEdit.aggregationType, + aggregationType: layerEdit?.aggregationType, }), { setAggregationType } )(AggregationTypeSelect) diff --git a/src/components/edit/thematic/CompletedOnlyCheckbox.jsx b/src/components/edit/thematic/CompletedOnlyCheckbox.jsx index 883adf40f..2ae55ddc9 100644 --- a/src/components/edit/thematic/CompletedOnlyCheckbox.jsx +++ b/src/components/edit/thematic/CompletedOnlyCheckbox.jsx @@ -29,7 +29,7 @@ CompletedOnlyCheckbox.propTypes = { export default connect( ({ layerEdit }) => ({ - completedOnly: layerEdit.eventStatus === EVENT_STATUS_COMPLETED, + completedOnly: layerEdit?.eventStatus === EVENT_STATUS_COMPLETED, }), { setEventStatus } )(CompletedOnlyCheckbox) diff --git a/src/components/edit/thematic/RadiusSelect.jsx b/src/components/edit/thematic/RadiusSelect.jsx index 155ef8a62..2b427e25a 100644 --- a/src/components/edit/thematic/RadiusSelect.jsx +++ b/src/components/edit/thematic/RadiusSelect.jsx @@ -77,8 +77,8 @@ RadiusSelect.propTypes = { export default connect( ({ layerEdit }) => ({ - radiusLow: layerEdit.radiusLow, - radiusHigh: layerEdit.radiusHigh, + radiusLow: layerEdit?.radiusLow, + radiusHigh: layerEdit?.radiusHigh, }), { setRadiusLow, setRadiusHigh } )(RadiusSelect) diff --git a/src/components/groupSet/StyleByGroupSet.jsx b/src/components/groupSet/StyleByGroupSet.jsx index ab937b287..a7a5dab2a 100644 --- a/src/components/groupSet/StyleByGroupSet.jsx +++ b/src/components/groupSet/StyleByGroupSet.jsx @@ -41,7 +41,7 @@ StyleByGroupSet.propTypes = { export default connect( ({ layerEdit }) => ({ - groupSet: layerEdit.organisationUnitGroupSet, + groupSet: layerEdit?.organisationUnitGroupSet, }), { setOrganisationUnitGroupSet } )(StyleByGroupSet) From 9f6f8995d8bc3f59a43b724348df70cf36f8c0fb Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 4 May 2026 23:14:14 +0200 Subject: [PATCH 3/8] chore: add tests --- src/util/__tests__/favorites.spec.js | 64 ++++++++++ src/util/__tests__/geojson.spec.js | 45 +++++++ src/util/__tests__/orgUnits.spec.js | 172 ++++++++++++++++++++++++++- 3 files changed, 280 insertions(+), 1 deletion(-) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 2445b4579..5189315bc 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -734,4 +734,68 @@ describe('cleanMapConfig', () => { ], }) }) + + test('serializes countFeaturesWithoutCoordinates into config JSON for thematic layer', () => { + const config = { + mapViews: [ + { + layer: 'thematic', + name: 'My Thematic', + rows: [], + countFeaturesWithoutCoordinates: true, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.countFeaturesWithoutCoordinates).toBe(true) + expect(mapView.countFeaturesWithoutCoordinates).toBeUndefined() + }) + + test('serializes countFeaturesWithoutCoordinates into config JSON for event layer', () => { + const config = { + mapViews: [ + { + layer: 'event', + name: 'My Events', + rows: [], + countFeaturesWithoutCoordinates: true, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.countFeaturesWithoutCoordinates).toBe(true) + expect(mapView.countFeaturesWithoutCoordinates).toBeUndefined() + }) + + test('preserves countFeaturesWithoutCoordinates as top-level property for earth engine layer', () => { + const config = { + mapViews: [ + { + layer: 'earthEngine', + layerId: 'NASA/FIRMS', + name: 'Fire', + rows: [], + countFeaturesWithoutCoordinates: true, + }, + ], + } + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'default', + }) + const mapView = cleanedConfig.mapViews[0] + expect(mapView.countFeaturesWithoutCoordinates).toBe(true) + const parsedConfig = JSON.parse(mapView.config) + expect(parsedConfig.countFeaturesWithoutCoordinates).toBeUndefined() + }) }) diff --git a/src/util/__tests__/geojson.spec.js b/src/util/__tests__/geojson.spec.js index 8cf0e92c8..7d4f1b814 100644 --- a/src/util/__tests__/geojson.spec.js +++ b/src/util/__tests__/geojson.spec.js @@ -103,6 +103,32 @@ describe('geojson utils', () => { const getter = buildEventGeometryGetter(headers) expect(getter(dummyEvent)).toEqual(point) }) + + it('Should return null when geometry value is null', () => { + const getter = buildEventGeometryGetter(headers) + const eventWithNoGeom = [ + 'MyID', + null, + JSON.stringify(stringCoords), + 54321, + arrayCoords, + 1234, + ] + expect(getter(eventWithNoGeom)).toBeNull() + }) + + it('Should return null when geometry value is empty string', () => { + const getter = buildEventGeometryGetter(headers) + const eventWithEmptyGeom = [ + 'MyID', + '', + JSON.stringify(stringCoords), + 54321, + arrayCoords, + 1234, + ] + expect(getter(eventWithEmptyGeom)).toBeNull() + }) }) describe('createEventFeatures', () => { @@ -235,6 +261,25 @@ describe('geojson utils', () => { })) ) }) + + it('Should split rows with null geometry into dataWithoutCoords', () => { + const noGeomRow = ['val', 'psi_nogeom', 'id_nogeom', null, 'other'] + const responseWithMissing = { + headers, + rows: [...rows, noGeomRow], + metaData, + } + const out = createEventFeatures(responseWithMissing) + expect(out.data).toHaveLength(rows.length) + expect(out.dataWithoutCoords).toHaveLength(1) + expect(out.dataWithoutCoords[0].id).toBe('psi_nogeom') + expect(out.dataWithoutCoords[0].geometry).toBeNull() + }) + + it('Should return empty dataWithoutCoords when all rows have geometry', () => { + const out = createEventFeatures(response) + expect(out.dataWithoutCoords).toHaveLength(0) + }) }) describe('addStyleDataItem', () => { diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 074ea7cb1..042fa2d73 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -1,4 +1,9 @@ -import { getStyledOrgUnits } from '../orgUnits.js' +import { + getStyledOrgUnits, + addGroupCountsToLegend, + getOrgUnitsWithoutCoordsCount, + fetchAndParseGroupSet, +} from '../orgUnits.js' describe('getStyledOrgUnits', () => { it('should return styled features and legend for facility layer', () => { @@ -39,6 +44,23 @@ describe('getStyledOrgUnits', () => { expect(result.legend.unit).toEqual('GroupSet1') }) + it('should return an empty facility item with count when no group set is selected', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { dimensions: {} }, + }, + ] + const result = getStyledOrgUnits({ + features, + groupSet: {}, + config: {}, + baseUrl: '/baseUrl', + }) + expect(result.legend.items).toHaveLength(1) + expect(result.legend.items[0]).toMatchObject({ name: 'Facility' }) + }) + it('should return styled features and legend for orgUnit layer', () => { const features = [ { @@ -76,3 +98,151 @@ describe('getStyledOrgUnits', () => { }) }) }) + +describe('addGroupCountsToLegend', () => { + it('should initialize all item counts to 0', () => { + const legendItems = [ + { id: 'g1', count: 5 }, + { id: 'g2', count: 3 }, + ] + addGroupCountsToLegend(legendItems, [], { id: 'gs1' }) + expect(legendItems[0].count).toBe(0) + expect(legendItems[1].count).toBe(0) + }) + + it('should increment count for features matching a group', () => { + const legendItems = [ + { id: 'g1', count: 0 }, + { id: 'g2', count: 0 }, + ] + const features = [ + { properties: { dimensions: { gs1: 'g1' } } }, + { properties: { dimensions: { gs1: 'g1' } } }, + { properties: { dimensions: { gs1: 'g2' } } }, + ] + addGroupCountsToLegend(legendItems, features, { id: 'gs1' }) + expect(legendItems[0].count).toBe(2) + expect(legendItems[1].count).toBe(1) + }) + + it('should not increment count for features with no matching group', () => { + const legendItems = [{ id: 'g1', count: 0 }] + const features = [{ properties: { dimensions: { gs1: 'g99' } } }] + addGroupCountsToLegend(legendItems, features, { id: 'gs1' }) + expect(legendItems[0].count).toBe(0) + }) + + it('should handle features with missing dimensions gracefully', () => { + const legendItems = [{ id: 'g1', count: 0 }] + const features = [ + { properties: {} }, + { properties: { dimensions: {} } }, + ] + addGroupCountsToLegend(legendItems, features, { id: 'gs1' }) + expect(legendItems[0].count).toBe(0) + }) +}) + +describe('getOrgUnitsWithoutCoordsCount', () => { + it('should return zero count immediately when orgUnitIds is empty', async () => { + const result = await getOrgUnitsWithoutCoordsCount({ + engine: {}, + orgUnitIds: [], + userId: 'user1', + features: [], + }) + expect(result).toEqual({ count: 0, missingOrgUnits: [] }) + }) + + it('should return error when no data element is found', async () => { + const engine = { + query: jest + .fn() + .mockResolvedValue({ dataElements: { dataElements: [] } }), + } + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds: ['ou1'], + userId: 'user1', + features: [], + }) + expect(result).toEqual({ error: true }) + }) + + it('should count org units present in analytics but absent from features', async () => { + const engine = { + query: jest + .fn() + .mockResolvedValueOnce({ + dataElements: { dataElements: [{ id: 'de1' }] }, + }) + .mockResolvedValueOnce({ + orgUnitsCount: { + metaData: { + dimensions: { ou: ['ou1', 'ou2', 'ou3'] }, + items: { + ou1: { name: 'OU 1' }, + ou2: { name: 'OU 2' }, + ou3: { name: 'OU 3' }, + }, + }, + }, + }), + } + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds: ['ou1', 'ou2', 'ou3'], + userId: 'user1', + features: [{ id: 'ou1' }], + }) + expect(result.count).toBe(2) + expect(result.missingOrgUnits).toHaveLength(2) + expect(result.missingOrgUnits.map((o) => o.id)).toEqual(['ou2', 'ou3']) + }) + + it('should return error when engine query throws', async () => { + const engine = { + query: jest.fn().mockRejectedValue(new Error('Network error')), + } + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds: ['ou1'], + userId: 'user1', + features: [], + }) + expect(result).toEqual({ error: true }) + }) +}) + +describe('fetchAndParseGroupSet', () => { + it('should return parsed group set on success', async () => { + const engine = { + query: jest.fn().mockResolvedValue({ + groupSets: { + name: 'My Group Set', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group 1', + color: '#ff0000', + symbol: '01.png', + }, + ], + }, + }), + } + const result = await fetchAndParseGroupSet(engine, { id: 'gs1' }) + expect(result).not.toBeNull() + expect(result.name).toBe('My Group Set') + expect(result.organisationUnitGroups).toHaveLength(1) + expect(result.organisationUnitGroups[0].id).toBe('g1') + }) + + it('should return null when query fails', async () => { + const engine = { + query: jest.fn().mockRejectedValue(new Error('Not found')), + } + const result = await fetchAndParseGroupSet(engine, { id: 'gs1' }) + expect(result).toBeNull() + }) +}) From 740baf44073cd1c0b1dbccb01b33cb11e18a47bc Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 4 May 2026 23:23:25 +0200 Subject: [PATCH 4/8] chore: fix sonarqube issue --- src/loaders/facilityLoader.js | 85 ++++++++++------------- src/loaders/orgUnitLoader.js | 102 ++++++++++++---------------- src/util/__tests__/orgUnits.spec.js | 85 +++++++++++++++++++++++ src/util/orgUnits.js | 32 +++++++++ 4 files changed, 199 insertions(+), 105 deletions(-) diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index 2c4e3070c..2ee398971 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -14,11 +14,35 @@ import { getCoordinateField, getOrgUnitsWithoutCoordsCount, addGroupCountsToLegend, - fetchAndParseGroupSet, + loadGroupSetData, fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +const applyMissingCoordsCount = async ( + config, + { engine, orgUnitIds, userId, features, legend, alerts } +) => { + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features, + }) + if (result.error) { + alerts.push({ + warning: true, + code: CUSTOM_ALERT, + message: i18n.t('Could not count org units without coordinates'), + }) + return + } + legend.orgUnitsWithoutCoordinatesCount = result.count + if (result.count > 0) { + config.dataWithoutCoords = result.missingOrgUnits + } +} + const facilityLoader = async ({ config, engine, @@ -33,7 +57,6 @@ const facilityLoader = async ({ const coordinateField = getCoordinateField(config) const name = i18n.t('Facilities') - let loadError const alerts = [] // Config parsing @@ -48,29 +71,16 @@ const facilityLoader = async ({ // Data loading // ----- - let data = {} + let data try { - // Fetch geofeatures data - data = await engine.query( - GEOFEATURES_QUERY, - { - variables: { - orgUnitIds, - keyAnalysisDisplayProperty, - includeGroupSets, - userId, - }, + data = await engine.query(GEOFEATURES_QUERY, { + variables: { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + userId, }, - { - onError: (error) => { - alerts.push({ - critical: true, - code: ERROR_CRITICAL, - message: error.message || i18n.t('an error occurred'), - }) - }, - } - ) + }) } catch (error) { alerts.push({ critical: true, @@ -83,16 +93,7 @@ const facilityLoader = async ({ ? toGeoJson(getPointItems(data.geoFeatures)) : [] - if (includeGroupSets && !groupSet.organisationUnitGroups) { - const parsedGroupSet = await fetchAndParseGroupSet(engine, groupSet) - if (parsedGroupSet) { - groupSet.organisationUnitGroups = - parsedGroupSet.organisationUnitGroups - groupSet.name = parsedGroupSet.name - } else { - loadError = i18n.t('GroupSet used for styling was not found') - } - } + const loadError = await loadGroupSetData(engine, groupSet, includeGroupSets) let associatedGeometries if (coordinateField) { @@ -135,26 +136,14 @@ const facilityLoader = async ({ } if (config.countFeaturesWithoutCoordinates) { - const result = await getOrgUnitsWithoutCoordsCount({ + await applyMissingCoordsCount(config, { engine, orgUnitIds, userId, features, + legend, + alerts, }) - if (result.error) { - alerts.push({ - warning: true, - code: CUSTOM_ALERT, - message: i18n.t( - 'Could not count org units without coordinates' - ), - }) - } else { - legend.orgUnitsWithoutCoordinatesCount = result.count - if (result.count > 0) { - config.dataWithoutCoords = result.missingOrgUnits - } - } } if (coordinateField) { diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 0fe647329..221061b25 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -16,11 +16,47 @@ import { getOrgUnitsWithoutCoordsCount, fetchOrgUnitDetails, addGroupCountsToLegend, - fetchAndParseGroupSet, + addLevelCountsToLegend, + loadGroupSetData, fetchAssociatedGeometries, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +const applyMissingCoordsCount = async ( + config, + { engine, orgUnitIds, userId, features, legend, alerts } +) => { + const result = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features, + }) + if (result.error) { + alerts.push({ + warning: true, + code: CUSTOM_ALERT, + message: i18n.t('Could not count org units without coordinates'), + }) + return + } + legend.orgUnitsWithoutCoordinatesCount = result.count + if (result.count > 0) { + const details = await fetchOrgUnitDetails( + engine, + result.missingOrgUnits.map((o) => o.id) + ) + config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({ + ...ou, + properties: { + ...ou.properties, + level: details[ou.id]?.level, + parentName: details[ou.id]?.parentName, + }, + })) + } +} + const orgUnitLoader = async ({ config, engine, @@ -35,7 +71,6 @@ const orgUnitLoader = async ({ const coordinateField = getCoordinateField(config) const name = i18n.t('Organisation units') - let loadError const alerts = [] // Config parsing @@ -79,18 +114,9 @@ const orgUnitLoader = async ({ }) } - const orgUnitLevels = await apiFetchOrganisationUnitLevels(engine) + const levels = await apiFetchOrganisationUnitLevels(engine) - if (includeGroupSets && !groupSet.organisationUnitGroups) { - const parsedGroupSet = await fetchAndParseGroupSet(engine, groupSet) - if (parsedGroupSet) { - groupSet.organisationUnitGroups = - parsedGroupSet.organisationUnitGroups - groupSet.name = parsedGroupSet.name - } else { - loadError = i18n.t('GroupSet used for styling was not found') - } - } + const loadError = await loadGroupSetData(engine, groupSet, includeGroupSets) let associatedGeometries if (coordinateField) { @@ -120,12 +146,8 @@ const orgUnitLoader = async ({ groupSet, config, baseUrl, - orgUnitLevels: orgUnitLevels.reduce( - (obj, item) => ({ - ...obj, - [item.level]: item.displayName, // orgUnitLevels do not have shortNames - }), - {} + orgUnitLevels: Object.fromEntries( + levels.map(({ level, displayName }) => [level, displayName]) ), }) @@ -134,52 +156,18 @@ const orgUnitLoader = async ({ if (groupSet?.id) { addGroupCountsToLegend(legend.items, mainFeatures, groupSet) } else { - legend.items.forEach((item) => (item.count = 0)) - mainFeatures.forEach((f) => { - const levelInfo = orgUnitLevels.find( - (l) => l.level === f.properties.level - ) - const item = - levelInfo && - legend.items.find((i) => i.name === levelInfo.displayName) - if (item) { - item.count++ - } - }) + addLevelCountsToLegend(legend.items, mainFeatures, levels) } if (config.countFeaturesWithoutCoordinates) { - const result = await getOrgUnitsWithoutCoordsCount({ + await applyMissingCoordsCount(config, { engine, orgUnitIds, userId, features: mainFeatures, + legend, + alerts, }) - if (result.error) { - alerts.push({ - warning: true, - code: CUSTOM_ALERT, - message: i18n.t( - 'Could not count org units without coordinates' - ), - }) - } else { - legend.orgUnitsWithoutCoordinatesCount = result.count - if (result.count > 0) { - const details = await fetchOrgUnitDetails( - engine, - result.missingOrgUnits.map((o) => o.id) - ) - config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({ - ...ou, - properties: { - ...ou.properties, - level: details[ou.id]?.level, - parentName: details[ou.id]?.parentName, - }, - })) - } - } } return { diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 042fa2d73..2c9d05ef0 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -1,8 +1,10 @@ import { getStyledOrgUnits, addGroupCountsToLegend, + addLevelCountsToLegend, getOrgUnitsWithoutCoordsCount, fetchAndParseGroupSet, + loadGroupSetData, } from '../orgUnits.js' describe('getStyledOrgUnits', () => { @@ -246,3 +248,86 @@ describe('fetchAndParseGroupSet', () => { expect(result).toBeNull() }) }) + +describe('addLevelCountsToLegend', () => { + const levels = [ + { level: 1, displayName: 'National' }, + { level: 2, displayName: 'District' }, + ] + + it('should initialize all item counts to 0', () => { + const legendItems = [ + { name: 'National', count: 5 }, + { name: 'District', count: 3 }, + ] + addLevelCountsToLegend(legendItems, [], levels) + expect(legendItems[0].count).toBe(0) + expect(legendItems[1].count).toBe(0) + }) + + it('should increment count for features matching a level', () => { + const legendItems = [ + { name: 'National', count: 0 }, + { name: 'District', count: 0 }, + ] + const features = [ + { properties: { level: 1 } }, + { properties: { level: 2 } }, + { properties: { level: 2 } }, + ] + addLevelCountsToLegend(legendItems, features, levels) + expect(legendItems[0].count).toBe(1) + expect(legendItems[1].count).toBe(2) + }) + + it('should not increment count for features with no matching level', () => { + const legendItems = [{ name: 'National', count: 0 }] + const features = [{ properties: { level: 99 } }] + addLevelCountsToLegend(legendItems, features, levels) + expect(legendItems[0].count).toBe(0) + }) +}) + +describe('loadGroupSetData', () => { + it('should return null when includeGroupSets is false', async () => { + const engine = { query: jest.fn() } + const result = await loadGroupSetData(engine, {}, false) + expect(result).toBeNull() + expect(engine.query).not.toHaveBeenCalled() + }) + + it('should return null when groupSet already has organisationUnitGroups', async () => { + const engine = { query: jest.fn() } + const groupSet = { id: 'gs1', organisationUnitGroups: [] } + const result = await loadGroupSetData(engine, groupSet, true) + expect(result).toBeNull() + expect(engine.query).not.toHaveBeenCalled() + }) + + it('should mutate groupSet and return null on success', async () => { + const engine = { + query: jest.fn().mockResolvedValue({ + groupSets: { + name: 'My Group Set', + organisationUnitGroups: [ + { id: 'g1', name: 'Group 1', color: '#ff0000' }, + ], + }, + }), + } + const groupSet = { id: 'gs1' } + const result = await loadGroupSetData(engine, groupSet, true) + expect(result).toBeNull() + expect(groupSet.name).toBe('My Group Set') + expect(groupSet.organisationUnitGroups).toHaveLength(1) + }) + + it('should return an error string when query fails', async () => { + const engine = { + query: jest.fn().mockRejectedValue(new Error('Not found')), + } + const groupSet = { id: 'gs1' } + const result = await loadGroupSetData(engine, groupSet, true) + expect(typeof result).toBe('string') + }) +}) diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index e69a3fcf8..d35351c95 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -316,6 +316,25 @@ export const addGroupCountsToLegend = (legendItems, features, groupSet) => { }) } +export const addLevelCountsToLegend = ( + legendItems, + features, + orgUnitLevels +) => { + legendItems.forEach((item) => (item.count = 0)) + features.forEach((f) => { + const levelInfo = orgUnitLevels.find( + (l) => l.level === f.properties.level + ) + const item = + levelInfo && + legendItems.find((i) => i.name === levelInfo.displayName) + if (item) { + item.count++ + } + }) +} + export const fetchAndParseGroupSet = async (engine, groupSet) => { try { const { groupSets } = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { @@ -332,6 +351,19 @@ export const fetchAndParseGroupSet = async (engine, groupSet) => { } } +export const loadGroupSetData = async (engine, groupSet, includeGroupSets) => { + if (!includeGroupSets || groupSet.organisationUnitGroups) { + return null + } + const parsed = await fetchAndParseGroupSet(engine, groupSet) + if (!parsed) { + return i18n.t('GroupSet used for styling was not found') + } + groupSet.organisationUnitGroups = parsed.organisationUnitGroups + groupSet.name = parsed.name + return null +} + export const fetchAssociatedGeometries = async ( engine, { From b1976070059ad613d4bb0f182df3667d8027e278 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 14 May 2026 12:05:43 +0200 Subject: [PATCH 5/8] fix: clarify missing org unit label for facility layers [DHIS2-19850] --- i18n/en.pot | 29 ++++++++++++++++++-------- src/components/edit/FacilityDialog.jsx | 2 +- src/components/legend/Legend.jsx | 24 ++++++++++++++++----- src/loaders/facilityLoader.js | 7 +++++-- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 8bb40a247..1f595fbf0 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-05-04T14:12:54.399Z\n" -"PO-Revision-Date: 2026-05-04T14:12:54.399Z\n" +"POT-Creation-Date: 2026-05-14T10:04:17.710Z\n" +"PO-Revision-Date: 2026-05-14T10:04:17.711Z\n" msgid "2020" msgstr "2020" @@ -267,8 +267,8 @@ msgstr "Organisation Units" msgid "Style" msgstr "Style" -msgid "Count org units without coordinates" -msgstr "Count org units without coordinates" +msgid "Count org units without a point location" +msgstr "Count org units without a point location" msgid "Point color" msgstr "Point color" @@ -383,6 +383,9 @@ msgstr "Valid classes are {{minSteps}} to {{maxSteps}}" msgid "Facility buffer" msgstr "Facility buffer" +msgid "Count org units without coordinates" +msgstr "Count org units without coordinates" + msgid "Org Units" msgstr "Org Units" @@ -697,6 +700,11 @@ msgid_plural "{{count}} event without coordinates" msgstr[0] "{{count}} event without coordinates" msgstr[1] "{{count}} events without coordinates" +msgid "{{count}} org unit without a point location" +msgid_plural "{{count}} org unit without a point location" +msgstr[0] "{{count}} org unit without a point location" +msgstr[1] "{{count}} org units without a point location" + msgid "{{count}} org unit without coordinates" msgid_plural "{{count}} org unit without coordinates" msgstr[0] "{{count}} org unit without coordinates" @@ -1768,17 +1776,17 @@ msgstr "Displaying first {{pageSize}} events out of {{total}}" msgid "Event" msgstr "Event" +msgid "Could not count org units without a point location" +msgstr "Could not count org units without a point location" + msgid "Facilities" msgstr "Facilities" msgid "an error occurred" msgstr "an error occurred" -msgid "GroupSet used for styling was not found" -msgstr "GroupSet used for styling was not found" - -msgid "No coordinates found for selected facilities" -msgstr "No coordinates found for selected facilities" +msgid "No point locations found for selected facilities" +msgstr "No point locations found for selected facilities" msgid "There was a problem with this layer. Contact a system administrator." msgstr "There was a problem with this layer. Contact a system administrator." @@ -1862,6 +1870,9 @@ msgstr "Org units" msgid "Facility" msgstr "Facility" +msgid "GroupSet used for styling was not found" +msgstr "GroupSet used for styling was not found" + msgid "Start date is invalid" msgstr "Start date is invalid" diff --git a/src/components/edit/FacilityDialog.jsx b/src/components/edit/FacilityDialog.jsx index ba1f8b5d6..b0d1ddebb 100644 --- a/src/components/edit/FacilityDialog.jsx +++ b/src/components/edit/FacilityDialog.jsx @@ -123,7 +123,7 @@ const FacilityDialog = ({ /> diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 1e5d995e2..748a44c2c 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -22,6 +22,7 @@ const Legend = ({ decimalPlaces, eventsWithoutCoordinatesCount, orgUnitsWithoutCoordinatesCount, + orgUnitsPointOnly = false, isPlugin = false, }) => { const showRange = Array.isArray(items) && !legendNamesContainRange(items) @@ -109,11 +110,23 @@ const Legend = ({ )} {typeof orgUnitsWithoutCoordinatesCount === 'number' && (
- {i18n.t('{{count}} org unit without coordinates', { - count: orgUnitsWithoutCoordinatesCount, - defaultValue_plural: - '{{count}} org units without coordinates', - })} + {orgUnitsPointOnly + ? i18n.t( + '{{count}} org unit without a point location', + { + count: orgUnitsWithoutCoordinatesCount, + defaultValue_plural: + '{{count}} org units without a point location', + } + ) + : i18n.t( + '{{count}} org unit without coordinates', + { + count: orgUnitsWithoutCoordinatesCount, + defaultValue_plural: + '{{count}} org units without coordinates', + } + )}
)}
@@ -149,6 +162,7 @@ Legend.propTypes = { groups: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), isPlugin: PropTypes.bool, items: PropTypes.array, + orgUnitsPointOnly: PropTypes.bool, orgUnitsWithoutCoordinatesCount: PropTypes.number, source: PropTypes.string, sourceUrl: PropTypes.string, diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index 2ee398971..0be2f4e5a 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -33,11 +33,14 @@ const applyMissingCoordsCount = async ( alerts.push({ warning: true, code: CUSTOM_ALERT, - message: i18n.t('Could not count org units without coordinates'), + message: i18n.t( + 'Could not count org units without a point location' + ), }) return } legend.orgUnitsWithoutCoordinatesCount = result.count + legend.orgUnitsPointOnly = true if (result.count > 0) { config.dataWithoutCoords = result.missingOrgUnits } @@ -164,7 +167,7 @@ const facilityLoader = async ({ alerts.push({ warning: true, code: CUSTOM_ALERT, - message: i18n.t('No coordinates found for selected facilities'), + message: i18n.t('No point locations found for selected facilities'), }) } From 9d9607acbd3c76f991508f7017746cb9ec8a2ee8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 14 May 2026 18:53:32 +0200 Subject: [PATCH 6/8] Merge remote-tracking branch 'origin/feat/DHIS2-18242-PR6' into feat/DHIS2-18242-PR7 --- CHANGELOG.md | 7 + docs/maps.md | 10 +- package.json | 2 +- src/components/edit/FacilityDialog.jsx | 15 ++ src/components/edit/orgUnit/OrgUnitDialog.jsx | 18 ++ src/loaders/facilityLoader.js | 6 + src/loaders/geoJsonUrlLoader.js | 2 +- src/loaders/orgUnitLoader.js | 6 + src/util/__tests__/filter.spec.js | 27 ++- src/util/__tests__/orgUnits.spec.js | 197 ++++++++++++++++++ src/util/filter.js | 14 +- src/util/orgUnits.js | 84 ++++---- 12 files changed, 341 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45408edf0..aa6aaee16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [101.11.0](https://github.com/dhis2/maps-app/compare/v101.10.2...v101.11.0) (2026-05-14) + + +### Features + +* add filter operators `<=` and `>=` and correct `<` and `>` in data table [DHIS2-19988] ([#3643](https://github.com/dhis2/maps-app/issues/3643)) ([363aaa0](https://github.com/dhis2/maps-app/commit/363aaa0b48578fd837ad47e296da2e345b9f9a11)) + ## [101.10.2](https://github.com/dhis2/maps-app/compare/v101.10.1...v101.10.2) (2026-04-22) diff --git a/docs/maps.md b/docs/maps.md index 0ab3e4067..92644e0c7 100644 --- a/docs/maps.md +++ b/docs/maps.md @@ -374,8 +374,11 @@ The data table displays the data forming the thematic layer. - VALUE - filter values by given numbers and/or ranges, for example: - 2,\>3&\<8 + filter values by given numbers and/or ranges using `>` (greater + than), `<` (less than), `>=` (greater than or equal), or `<=` + (less than or equal). Use `,` for OR and `&` for AND logic, for + example: `2,>3&<8` matches the value 2, or any value greater + than 3 and less than 8. - LEGEND @@ -387,7 +390,8 @@ The data table displays the data forming the thematic layer. - LEVEL - filter level by numbers and/or ranges, for example: 2,\>3&\<8 + filter level by numbers and/or ranges, using the same operators + as VALUE, for example: `2,>3&<8` - PARENT diff --git a/package.json b/package.json index d3066fd9e..96dc22f9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maps-app", - "version": "101.10.2", + "version": "101.11.0", "description": "DHIS2 Maps", "license": "BSD-3-Clause", "author": "Bjørn Sandvik", diff --git a/src/components/edit/FacilityDialog.jsx b/src/components/edit/FacilityDialog.jsx index b0d1ddebb..7bd6d1deb 100644 --- a/src/components/edit/FacilityDialog.jsx +++ b/src/components/edit/FacilityDialog.jsx @@ -10,6 +10,7 @@ import { setOrganisationUnitGroupSet, setOrganisationUnitColor, setCountFeaturesWithoutCoordinates, + setUnclassifiedLegend, } from '../../actions/layerEdit.js' import { ORG_UNIT_COLOR, @@ -26,6 +27,7 @@ import StyleByGroupSet from '../groupSet/StyleByGroupSet.jsx' import OrgUnitSelect from '../orgunits/OrgUnitSelect.jsx' import BufferRadius from './shared/BufferRadius.jsx' import Labels from './shared/Labels.jsx' +import UnclassifiedLegend from './shared/UnclassifiedLegend.jsx' import styles from './styles/LayerDialog.module.css' const QUERY = { @@ -42,6 +44,7 @@ const FacilityDialog = ({ radiusLow, organisationUnitColor, organisationUnitGroupSet, + unclassifiedLegend, orgUnitField, id, validateLayer, @@ -139,6 +142,14 @@ const FacilityDialog = ({ + {organisationUnitGroupSet && ( + + dispatch(setUnclassifiedLegend(v)) + } + /> + )} {!organisationUnitGroupSet && ( <> + {organisationUnitGroupSet && ( + + )}
)} @@ -161,6 +178,7 @@ export default connect( setRadiusLow, setOrganisationUnitColor, setCountFeaturesWithoutCoordinates, + setUnclassifiedLegend, }, null, { diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index 0be2f4e5a..99f01eae8 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -62,6 +62,12 @@ const facilityLoader = async ({ const name = i18n.t('Facilities') const alerts = [] + const { unclassifiedLegend } = parseJsonConfig(config.config) + if (unclassifiedLegend) { + config.unclassifiedLegend = unclassifiedLegend + } + delete config.config + // Config parsing // ----- diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index 55dffe115..388ad48c3 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -119,7 +119,7 @@ const geoJsonUrlLoader = async ({ return { ...layer, - name: newConfig.name, // Overrides layer.name from spread — redundant on 2.42+ (DHIS2-16088), remove when 2.41 support is dropped + name: newConfig.name, // VERSION-TOGGLE: remove when 41 is lowest supported version, overrides layer.name from spread (DHIS2-16088) legend, data, keyAnalysisDigitGroupSeparator, diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 221061b25..8da444842 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -73,6 +73,12 @@ const orgUnitLoader = async ({ const name = i18n.t('Organisation units') const alerts = [] + const { unclassifiedLegend } = parseJsonConfig(config.config) + if (unclassifiedLegend) { + config.unclassifiedLegend = unclassifiedLegend + } + delete config.config + // Config parsing // ----- diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index ded981022..c23cda725 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -21,14 +21,13 @@ describe('filterData', () => { expect(filterData(data, filters)).toEqual([{ a: '2' }]) }) - // TODO the following tests fail because the code has a bug - it.skip('should filter data based on a numeric filter', () => { + it('should filter data based on a numeric filter', () => { const data = [{ a: 1 }, { a: 2 }, { a: 3 }] const filters = { a: '>1' } expect(filterData(data, filters)).toEqual([{ a: 2 }, { a: 3 }]) }) - it.skip('should handle complex numeric filters', () => { + it('should handle complex numeric filters', () => { const data = [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }, { a: 5 }] const filters = { a: '>1&<5' } expect(filterData(data, filters)).toEqual([ @@ -38,6 +37,28 @@ describe('filterData', () => { ]) }) + it('should filter data using >= operator', () => { + const data = [{ a: 1 }, { a: 2 }, { a: 3 }] + const filters = { a: '>=2' } + expect(filterData(data, filters)).toEqual([{ a: 2 }, { a: 3 }]) + }) + + it('should filter data using <= operator', () => { + const data = [{ a: 1 }, { a: 2 }, { a: 3 }] + const filters = { a: '<=2' } + expect(filterData(data, filters)).toEqual([{ a: 1 }, { a: 2 }]) + }) + + it('should handle complex numeric filters with >= and <=', () => { + const data = [{ a: 1 }, { a: 2 }, { a: 3 }, { a: 4 }, { a: 5 }] + const filters = { a: '>=2&<=4' } + expect(filterData(data, filters)).toEqual([ + { a: 2 }, + { a: 3 }, + { a: 4 }, + ]) + }) + it('should handle multiple string filters', () => { const data = [ { a: 'apple', b: 'cow' }, diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 2c9d05ef0..fe3c12dad 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -99,6 +99,203 @@ describe('getStyledOrgUnits', () => { items: [{ name: 'Level1' }], }) }) + + it('should filter out unclassified facility when unclassifiedLegend not set', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { + hasAdditionalGeometry: false, + dimensions: {}, + }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { radiusLow: 10 } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.styledFeatures).toHaveLength(0) + expect(result.legend.items).not.toContainEqual( + expect.objectContaining({ name: 'Unclassified' }) + ) + }) + + it('should include unclassified facility with unclassifiedLegend color when set', () => { + const unclassifiedColor = '#aabbcc' + const features = [ + { + geometry: { type: 'Point' }, + properties: { + hasAdditionalGeometry: false, + dimensions: {}, + }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { + radiusLow: 10, + unclassifiedLegend: { color: unclassifiedColor }, + } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.styledFeatures).toHaveLength(1) + expect(result.styledFeatures[0].properties.color).toBe( + unclassifiedColor + ) + expect(result.styledFeatures[0].properties.iconUrl).toBeUndefined() + expect(result.legend.items).toContainEqual( + expect.objectContaining({ + name: 'Unclassified', + color: unclassifiedColor, + }) + ) + }) + + it('should use custom name from unclassifiedLegend when provided', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { hasAdditionalGeometry: false, dimensions: {} }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { + radiusLow: 10, + unclassifiedLegend: { color: '#cccccc', name: 'Unknown' }, + } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.legend.items).toContainEqual( + expect.objectContaining({ name: 'Unknown' }) + ) + }) + + it('should always add Unclassified legend item when unclassifiedLegend is set, even if all features are matched', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { + hasAdditionalGeometry: false, + dimensions: { gs1: 'g1' }, + }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { + radiusLow: 10, + unclassifiedLegend: { color: '#cccccc' }, + } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.legend.items).toContainEqual( + expect.objectContaining({ name: 'Unclassified', color: '#cccccc' }) + ) + }) + + it('should include unclassified orgunit with unclassifiedLegend color when set', () => { + const unclassifiedColor = '#aabbcc' + const features = [ + { + geometry: { type: 'Polygon' }, + properties: { level: 1, dimensions: {} }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { id: 'g1', name: 'Group1', color: '#ff0000' }, + ], + } + const config = { + organisationUnitColor: 'red', + radiusLow: 10, + unclassifiedLegend: { color: unclassifiedColor }, + } + const orgUnitLevels = { 1: 'Level1' } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + orgUnitLevels, + }) + + expect(result.styledFeatures).toHaveLength(1) + expect(result.styledFeatures[0].properties.color).toBe( + unclassifiedColor + ) + expect(result.legend.items).toContainEqual( + expect.objectContaining({ + name: 'Unclassified', + color: unclassifiedColor, + }) + ) + }) }) describe('addGroupCountsToLegend', () => { diff --git a/src/util/filter.js b/src/util/filter.js index 57d524e71..ae5d72296 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -42,14 +42,20 @@ export const numericFilter = (value, filter) => { // Returns true if the filter is true const isTrueFilter = (value, filter) => { + if (filter.includes('>=')) { + return value >= Number(filter.split('>=')[1]) + } + + if (filter.includes('<=')) { + return value <= Number(filter.split('<=')[1]) + } + if (filter.includes('>')) { - // GREATER THAN - return value >= Number(filter.split('>')[1]) + return value > Number(filter.split('>')[1]) } if (filter.includes('<')) { - // LESS THAN - return value <= Number(filter.split('<')[1]) + return value < Number(filter.split('<')[1]) } return value === Number(filter) // Equal number diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index d35351c95..ac691a37c 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -108,6 +108,7 @@ export const getStyledOrgUnits = ({ config: { organisationUnitColor = ORG_UNIT_COLOR, radiusLow = ORG_UNIT_RADIUS, + unclassifiedLegend, }, baseUrl, orgUnitLevels, @@ -138,46 +139,51 @@ export const getStyledOrgUnits = ({ const useColor = styleType === STYLE_TYPE_COLOR - let styledFeatures = features.map((f) => { - const isPoint = f.geometry.type === 'Point' - const { hasAdditionalGeometry } = f.properties - const { color, symbol } = getOrgUnitStyle( - f.properties.dimensions, - groupSet - ) - let radius - - if (isPoint) { - radius = hasAdditionalGeometry - ? ORG_UNIT_RADIUS_SMALL + 1 - : radiusLow - } + const styledFeatures = features + .map((f) => { + const isPoint = f.geometry.type === 'Point' + const { hasAdditionalGeometry } = f.properties + const { color, symbol } = getOrgUnitStyle( + f.properties.dimensions, + groupSet + ) + const isUnclassified = !!groupSet.id && !color && !symbol + let radius - const properties = { - ...f.properties, - radius, - } + if (isPoint) { + radius = hasAdditionalGeometry + ? ORG_UNIT_RADIUS_SMALL + 1 + : radiusLow + } - if (useColor && color) { - properties.color = hasAdditionalGeometry ? ORG_UNIT_COLOR : color - } else if (symbol) { - properties.iconUrl = `${baseUrl}/images/orgunitgroup/${symbol}` - } + const properties = { + ...f.properties, + radius, + } - if (properties.level && levelWeight) { - properties.weight = levelWeight(f.properties.level) - } + if (isUnclassified && unclassifiedLegend) { + properties.color = unclassifiedLegend.color + } else if (useColor && color) { + properties.color = hasAdditionalGeometry + ? ORG_UNIT_COLOR + : color + } else if (symbol) { + properties.iconUrl = `${baseUrl}/images/orgunitgroup/${symbol}` + } - return { - ...f, - properties, - } - }) + if (properties.level && levelWeight) { + properties.weight = levelWeight(f.properties.level) + } - // Only include facilities having a group membership - if (isFacilityLayer && groupSet.id && !useColor) { - styledFeatures = styledFeatures.filter((f) => f.properties.iconUrl) - } + return { + ...f, + properties, + } + }) + .filter( + (f) => + !groupSet.id || !!f.properties.iconUrl || !!f.properties.color + ) const groupItems = getOrgUnitGroupLegendItems( organisationUnitGroups, @@ -185,6 +191,14 @@ export const getStyledOrgUnits = ({ baseUrl ) + if (unclassifiedLegend && groupSet.id) { + groupItems.push({ + name: unclassifiedLegend.name || i18n.t('Unclassified'), + color: unclassifiedLegend.color, + ...(isFacilityLayer && !useColor ? { radius: radiusLow } : {}), + }) + } + const facilityItems = isFacilityLayer && !groupSet.id ? [ From 046857438b70d1c9e56af3d681bbd4c3008c8e87 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 14 May 2026 19:04:51 +0200 Subject: [PATCH 7/8] chore: lint errors --- src/loaders/facilityLoader.js | 3 ++- src/loaders/orgUnitLoader.js | 3 ++- src/util/favorites.js | 14 +++++++------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index 6dcbf0844..db0be8196 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -65,7 +65,8 @@ const facilityLoader = async ({ // Config parsing // ----- - const { countFeaturesWithoutCoordinates, unclassifiedLegend } = parseJsonConfig(config.config) + const { countFeaturesWithoutCoordinates, unclassifiedLegend } = + parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true } diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 5726487a2..64f2711f3 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -76,7 +76,8 @@ const orgUnitLoader = async ({ // Config parsing // ----- - const { countFeaturesWithoutCoordinates, unclassifiedLegend } = parseJsonConfig(config.config) + const { countFeaturesWithoutCoordinates, unclassifiedLegend } = + parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true } diff --git a/src/util/favorites.js b/src/util/favorites.js index 1e0873b85..1e90f4a73 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -202,13 +202,13 @@ const models2objects = (layer, cleanMapviewConfig) => { layer.config = JSON.stringify({ relationships: layer.relationshipType ? { - type: layer.relationshipType, - pointColor: layer.relatedPointColor, - pointRadius: layer.relatedPointRadius, - lineColor: layer.relationshipLineColor, - relationshipOutsideProgram: - layer.relationshipOutsideProgram, - } + type: layer.relationshipType, + pointColor: layer.relatedPointColor, + pointRadius: layer.relatedPointRadius, + lineColor: layer.relationshipLineColor, + relationshipOutsideProgram: + layer.relationshipOutsideProgram, + } : null, periodType: layer.periodType, }) From 68ea5f55d47dc7a82a7dc9371d516a96281064a9 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 18 May 2026 15:09:05 +0200 Subject: [PATCH 8/8] fix: apply suggestions from code review Co-authored-by: Hendrik de Graaf --- src/util/orgUnits.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index ac691a37c..1d89a4f79 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -309,7 +309,7 @@ export const fetchOrgUnitDetails = async (engine, ids) => { ) return results.reduce((acc, result) => { - ;(result.orgUnits.organisationUnits || []).forEach((ou) => { + result.orgUnits.organisationUnits?.forEach((ou) => { acc[ou.id] = { level: ou.level, parentName: ou.parent?.name } }) return acc @@ -323,7 +323,7 @@ export const addGroupCountsToLegend = (legendItems, features, groupSet) => { legendItems.forEach((item) => (item.count = 0)) features.forEach((f) => { const groupId = f.properties?.dimensions?.[groupSet.id] - const item = legendItems.find((i) => i.id === groupId) + const item = groupId && legendItems.find((i) => i.id === groupId) if (item) { item.count++ }