From 7d6c5a1bb555d895ab0a7415e414d574c49bc4e2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 17:24:47 +0200 Subject: [PATCH 01/26] chore: refactor EventDialog to functional component [DHIS2-18209] --- src/components/edit/event/EventDialog.jsx | 818 ++++++++++------------ 1 file changed, 384 insertions(+), 434 deletions(-) diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index fd3b8c1bed..1f89c219d1 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -1,8 +1,8 @@ import i18n from '@dhis2/d2-i18n' import { NoticeBox, IconErrorFilled24 } from '@dhis2/ui' import PropTypes from 'prop-types' -import React, { Component } from 'react' -import { connect } from 'react-redux' +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { useDispatch } from 'react-redux' import { setProgram, setProgramStage, @@ -28,6 +28,7 @@ import { MAX_RADIUS, } from '../../../constants/layers.js' import { START_END_DATES } from '../../../constants/periods.js' +import usePrevious from '../../../hooks/usePrevious.js' import { getPeriodFromFilters, getOrgUnitsFromRows, @@ -59,468 +60,174 @@ import GeometryCentroid from '../shared/GeometryCentroid.jsx' import styles from '../styles/LayerDialog.module.css' 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, - setEventPointColor: PropTypes.func.isRequired, - setEventPointRadius: PropTypes.func.isRequired, - setEventStatus: PropTypes.func.isRequired, - // setFallbackCoordinateField: PropTypes.func.isRequired, - setOrgUnits: PropTypes.func.isRequired, - setPeriod: PropTypes.func.isRequired, - setProgram: PropTypes.func.isRequired, - setProgramStage: PropTypes.func.isRequired, - setStartDate: PropTypes.func.isRequired, - validateLayer: PropTypes.bool.isRequired, - onLayerValidation: PropTypes.func.isRequired, - backupPeriodsDates: PropTypes.object, - columns: PropTypes.array, - countFeaturesWithoutCoordinates: PropTypes.bool, - endDate: PropTypes.string, - eventClustering: PropTypes.bool, - eventCoordinateField: PropTypes.string, - eventCoordinateFieldType: PropTypes.string, - eventPointColor: PropTypes.string, - eventPointRadius: PropTypes.number, - eventStatus: PropTypes.string, - // fallbackCoordinateField: PropTypes.string, - filters: PropTypes.array, - legendIsolated: PropTypes.object, - legendSet: PropTypes.object, - method: PropTypes.number, - orgUnits: PropTypes.object, - periodsSettings: PropTypes.object, - program: PropTypes.shape({ - id: PropTypes.string.isRequired, - trackedEntityType: PropTypes.object, - }), - programStage: PropTypes.shape({ - id: PropTypes.string.isRequired, - }), - rows: PropTypes.array, - startDate: PropTypes.string, - styleDataItem: PropTypes.shape({ - id: PropTypes.string.isRequired, - optionSet: PropTypes.shape({ - options: PropTypes.array, - }), - }), - systemSettings: PropTypes.object, - } +const DEFAULT_NO_COLUMNS = [] +const DEFAULT_NO_FILTERS = [] - constructor(props, context) { - super(props, context) - this.state = { - tab: 'data', - } - } +const EventDialog = ({ + backupPeriodsDates, + columns = DEFAULT_NO_COLUMNS, + countFeaturesWithoutCoordinates, + endDate, + eventClustering, + eventCoordinateField, + eventCoordinateFieldType, + eventPointColor, + eventPointRadius, + eventStatus, + filters = DEFAULT_NO_FILTERS, + legendIsolated, + legendSet, + method, + orgUnits, + periodsSettings, + program, + programStage, + rows, + startDate, + styleDataItem, + systemSettings, + validateLayer, + onLayerValidation, +}) => { + const dispatch = useDispatch() + const [tab, setTab] = useState('data') + const [programError, setProgramError] = useState() + const [programStageError, setProgramStageError] = useState() + const [periodError, setPeriodError] = useState() + const [orgUnitsError, setOrgUnitsError] = useState() + const [legendSetError, setLegendSetError] = useState() + const [, setIsolatedClassError] = useState() + const [, setStyleDataItemError] = useState() - componentDidMount() { - const { - rows, - filters, - systemSettings, - startDate, - endDate, - orgUnits, - setPeriod, - setStartDate, - setEndDate, - setOrgUnits, - backupPeriodsDates, - } = this.props + const prevStartDate = usePrevious(startDate) + const prevEndDate = usePrevious(endDate) + const prevFilters = usePrevious(filters) + const currentPeriod = getPeriodFromFilters(filters) - const period = getPeriodFromFilters(filters) + // Seeded with the current period so this is a no-op on mount + const prevPeriodRef = useRef(currentPeriod) + + // Set default period from system settings + useEffect(() => { const { keyAnalysisRelativePeriod: defaultPeriod, hiddenPeriods } = systemSettings - const hasDate = startDate !== undefined && endDate !== undefined - // Set default period from system settings if ( - !period && + !currentPeriod && !hasDate && defaultPeriod && isPeriodAvailable(defaultPeriod, hiddenPeriods) ) { - setPeriod({ - id: defaultPeriod, - }) + dispatch(setPeriod({ id: defaultPeriod })) } + }, [currentPeriod, systemSettings, startDate, endDate, dispatch]) + + // Set default start/end dates + useEffect(() => { + const hasDate = startDate !== undefined && endDate !== undefined - // Set default dates if (!hasDate && !backupPeriodsDates) { const defaultDates = getDefaultDatesInCalendar() - setStartDate(defaultDates.startDate) - setEndDate(defaultDates.endDate) + dispatch(setStartDate(defaultDates.startDate)) + dispatch(setEndDate(defaultDates.endDate)) } + }, [startDate, endDate, backupPeriodsDates, dispatch]) - // Set org unit tree roots as default - if (!rows && orgUnits.roots) { - setOrgUnits({ - dimension: 'ou', - items: orgUnits.roots, - }) - } - } - - componentDidUpdate(prev) { - const { - validateLayer, - onLayerValidation, - filters, - startDate, - endDate, - setBackupPeriodsDates, - setStartDate, - setEndDate, - backupPeriodsDates, - } = this.props - const { periodError } = this.state - - if (validateLayer && validateLayer !== prev.validateLayer) { - onLayerValidation(this.validate()) + // Set org unit tree roots as default + useEffect(() => { + if (!rows && orgUnits?.roots) { + dispatch( + setOrgUnits({ + dimension: 'ou', + items: orgUnits.roots, + }) + ) } + }, [rows, orgUnits, dispatch]) - const prevPeriod = getPeriodFromFilters(prev.filters) - const currentPeriod = getPeriodFromFilters(filters) + useEffect(() => { + const prevPeriod = prevPeriodRef.current if (prevPeriod === undefined && currentPeriod !== undefined) { - setBackupPeriodsDates({ startDate, endDate }) - setStartDate() - setEndDate() + dispatch(setBackupPeriodsDates({ startDate, endDate })) + dispatch(setStartDate()) + dispatch(setEndDate()) } else if (prevPeriod !== undefined && currentPeriod === undefined) { - setStartDate(backupPeriodsDates?.startDate) - setEndDate(backupPeriodsDates?.endDate) + dispatch(setStartDate(backupPeriodsDates?.startDate)) + dispatch(setEndDate(backupPeriodsDates?.endDate)) } + prevPeriodRef.current = currentPeriod + }, [currentPeriod, startDate, endDate, backupPeriodsDates, dispatch]) + + // Clear period error when dates or period change + useEffect(() => { + const prevPeriod = getPeriodFromFilters(prevFilters) + if ( periodError && - (startDate !== prev.startDate || - endDate !== prev.endDate || + (startDate !== prevStartDate || + endDate !== prevEndDate || currentPeriod !== prevPeriod) ) { - this.setErrorState('periodError', null, 'period') - } - } - - render() { - const { - // layer options - columns = [], - countFeaturesWithoutCoordinates, - eventClustering, - eventStatus, - eventCoordinateField, - eventCoordinateFieldType, - eventPointColor, - eventPointRadius, - // fallbackCoordinateField, - filters = [], - program, - programStage, - legendSet, - periodsSettings, - } = this.props - - const { - // handlers - setProgram, - setProgramStage, - setEventStatus, - setEventCoordinateField, - setEventClustering, - setEventPointColor, - setEventPointRadius, - // setFallbackCoordinateField, - setPeriod, - setCountFeaturesWithoutCoordinates, - } = this.props - - const { - tab, - programError, - programStageError, - periodError, - orgUnitsError, - legendSetError, - } = this.state - - const period = getPeriodFromFilters(filters) || { - id: START_END_DATES, + setPeriodError(undefined) } + }, [ + periodError, + startDate, + prevStartDate, + endDate, + prevEndDate, + currentPeriod, + prevFilters, + ]) - return ( -
- this.setState({ tab })}> - {i18n.t('Data')} - {i18n.t('Period')} - {i18n.t('Org Units')} - {i18n.t('Filter')} - {i18n.t('Style')} - -
- {tab === 'data' && ( -
- - {program && ( - - )} - - - {/* eventCoordinateField && ( - - ) */} - -
- )} - {tab === 'period' && ( -
- - {period && period.id === START_END_DATES && ( - - )} - {periodError && ( -
- - {periodError} -
- )} -
- )} - {tab === 'orgunits' && ( - - )} - {tab === 'filter' && ( -
- c.filter !== undefined - ) - )} - /> -
- )} - {tab === 'style' && ( -
-
-
- setEventClustering(true)} - isSelected={eventClustering} - className={styles.flexInnerColumn} - /> - - setEventClustering(false) - } - isSelected={!eventClustering} - className={styles.flexInnerColumn} - /> -
-
- - -
- - - -
-
- {program ? ( - - ) : ( -
- - {i18n.t( - 'You can style events by data element after selecting a program.' - )} - -
- )} -
-
- )} -
-
- ) - } - - // TODO: Add to parent class? - setErrorState(key, message, tab) { - this.setState({ - [key]: message, - tab, - }) - - return false - } - - validate() { - const { - program, - programStage, - rows, - filters, - startDate, - endDate, - method, - legendSet, - styleDataItem, - legendIsolated, - } = this.props - + // Layer validation function + const validate = useCallback(() => { const period = getPeriodFromFilters(filters) || { id: START_END_DATES, } if (!program) { - return this.setErrorState( - 'programError', - i18n.t('Program is required'), - 'data' - ) + setProgramError(i18n.t('Program is required')) + setTab('data') + return false } if (!programStage) { - return this.setErrorState( - 'programStageError', - i18n.t('Program stage is required'), - 'data' - ) + setProgramStageError(i18n.t('Program stage is required')) + setTab('data') + return false } if (period.id === START_END_DATES) { const error = getStartEndDateError(startDate, endDate) if (error) { - return this.setErrorState('periodError', error, 'period') + setPeriodError(error) + setTab('period') + return false } } if (!getOrgUnitsFromRows(rows).length) { - return this.setErrorState( - 'orgUnitsError', - i18n.t('No organisation units are selected.'), - 'orgunits' - ) + setOrgUnitsError(i18n.t('No organisation units are selected.')) + setTab('orgunits') + return false } if (method === CLASSIFICATION_PREDEFINED && !legendSet) { - return this.setErrorState( - 'legendSetError', - i18n.t('No legend set is selected'), - 'style' - ) + setLegendSetError(i18n.t('No legend set is selected')) + setTab('style') + return false } if (!isValidIsolatedClass(legendIsolated)) { - return this.setErrorState( - 'isolatedClassError', - i18n.t('Isolated class max should be greater than min'), - 'style' + setIsolatedClassError( + i18n.t('Isolated class max should be greater than min') ) + setTab('style') + return false } if ( @@ -529,33 +236,276 @@ class EventDialog extends Component { !styleDataItem.optionSet.options ) { // Occurs when there are too many options - return this.setErrorState('styleDataItemError', '', 'style') + setStyleDataItemError('') + setTab('style') + return false } return true - } + }, [ + program, + programStage, + filters, + startDate, + endDate, + rows, + method, + legendSet, + legendIsolated, + styleDataItem, + ]) + + // Run layer validation + useEffect(() => { + if (validateLayer) { + onLayerValidation(validate()) + } + }, [validateLayer, onLayerValidation, validate]) + + const period = currentPeriod || { id: START_END_DATES } + + return ( +
+ + {i18n.t('Data')} + {i18n.t('Period')} + {i18n.t('Org Units')} + {i18n.t('Filter')} + {i18n.t('Style')} + +
+ {tab === 'data' && ( +
+ dispatch(setProgram(val))} + className={styles.select} + errorText={programError} + data-test="eventdialog-programselect" + /> + {program && ( + + dispatch(setProgramStage(val)) + } + className={styles.select} + errorText={programStageError} + /> + )} + + dispatch(setEventCoordinateField(val)) + } + className={styles.select} + data-test="eventdialog-coordinatefield" + /> + + {/* eventCoordinateField && ( + + ) */} + dispatch(setEventStatus(val))} + className={styles.select} + /> +
+ )} + {tab === 'period' && ( +
+ dispatch(setPeriod(val))} + className={styles.select} + /> + {period && period.id === START_END_DATES && ( + + dispatch(setStartDate(val)) + } + onSelectEndDate={(val) => + dispatch(setEndDate(val)) + } + periodsSettings={periodsSettings} + /> + )} + {periodError && ( +
+ + {periodError} +
+ )} +
+ )} + {tab === 'orgunits' && ( + + )} + {tab === 'filter' && ( +
+ c.filter !== undefined) + )} + /> +
+ )} + {tab === 'style' && ( +
+
+
+ + dispatch(setEventClustering(true)) + } + isSelected={eventClustering} + className={styles.flexInnerColumn} + /> + + dispatch(setEventClustering(false)) + } + isSelected={!eventClustering} + className={styles.flexInnerColumn} + /> +
+
+ + dispatch(setEventPointColor(val)) + } + className={styles.flexInnerColumn} + /> + + dispatch(setEventPointRadius(val)) + } + /> +
+ + + + dispatch( + setCountFeaturesWithoutCoordinates( + checked + ) + ) + } + /> +
+
+ {program ? ( + + ) : ( +
+ + {i18n.t( + 'You can style events by data element after selecting a program.' + )} + +
+ )} +
+
+ )} +
+
+ ) +} + +EventDialog.propTypes = { + validateLayer: PropTypes.bool.isRequired, + onLayerValidation: PropTypes.func.isRequired, + backupPeriodsDates: PropTypes.object, + columns: PropTypes.array, + countFeaturesWithoutCoordinates: PropTypes.bool, + endDate: PropTypes.string, + eventClustering: PropTypes.bool, + eventCoordinateField: PropTypes.string, + eventCoordinateFieldType: PropTypes.string, + eventPointColor: PropTypes.string, + eventPointRadius: PropTypes.number, + eventStatus: PropTypes.string, + // fallbackCoordinateField: PropTypes.string, + filters: PropTypes.array, + legendIsolated: PropTypes.object, + legendSet: PropTypes.object, + method: PropTypes.number, + orgUnits: PropTypes.object, + periodsSettings: PropTypes.object, + program: PropTypes.shape({ + id: PropTypes.string.isRequired, + trackedEntityType: PropTypes.object, + }), + programStage: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), + rows: PropTypes.array, + startDate: PropTypes.string, + styleDataItem: PropTypes.shape({ + id: PropTypes.string.isRequired, + optionSet: PropTypes.shape({ + options: PropTypes.array, + }), + }), + systemSettings: PropTypes.object, } -export default connect( - null, - { - setProgram, - setProgramStage, - setEventStatus, - setEventCoordinateField, - setEventClustering, - setEventPointColor, - setEventPointRadius, - // setFallbackCoordinateField, - setPeriod, - setBackupPeriodsDates, - setStartDate, - setEndDate, - setOrgUnits, - setCountFeaturesWithoutCoordinates, - }, - null, - { - forwardRef: true, - } -)(EventDialog) +export default EventDialog From ccd1c78665d18f5d0871cd5c4435bc68c813a47a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 19 Jun 2026 11:14:50 +0200 Subject: [PATCH 02/26] fix: prevent default relative period from re-applying after switching to start/end dates [DHIS2-18209] --- src/components/edit/event/EventDialog.jsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 1f89c219d1..20c6e0845c 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -116,12 +116,20 @@ const EventDialog = ({ if ( !currentPeriod && !hasDate && + !backupPeriodsDates && defaultPeriod && isPeriodAvailable(defaultPeriod, hiddenPeriods) ) { dispatch(setPeriod({ id: defaultPeriod })) } - }, [currentPeriod, systemSettings, startDate, endDate, dispatch]) + }, [ + currentPeriod, + systemSettings, + startDate, + endDate, + backupPeriodsDates, + dispatch, + ]) // Set default start/end dates useEffect(() => { From d3a04856f0e2b54668d6bb247ccc0fae07acec15 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 19 Jun 2026 12:28:11 +0200 Subject: [PATCH 03/26] fix: forward both arguments to setEventCoordinateField to prevent infinite render loop [DHIS2-18209] --- i18n/en.pot | 34 +++++++++++------------ src/components/edit/event/EventDialog.jsx | 6 ++-- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c14220f6f3..e1a95b300a 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-06-14T15:42:55.879Z\n" -"PO-Revision-Date: 2026-06-14T15:42:55.879Z\n" +"POT-Creation-Date: 2026-06-19T10:22:25.345Z\n" +"PO-Revision-Date: 2026-06-19T10:22:25.345Z\n" msgid "2020" msgstr "2020" @@ -386,6 +386,21 @@ msgstr "Facility buffer" msgid "Count org units without coordinates" msgstr "Count org units without coordinates" +msgid "Program is required" +msgstr "Program is required" + +msgid "Program stage is required" +msgstr "Program stage is required" + +msgid "No organisation units are selected." +msgstr "No organisation units are selected." + +msgid "No legend set is selected" +msgstr "No legend set is selected" + +msgid "Isolated class max should be greater than min" +msgstr "Isolated class max should be greater than min" + msgid "Org Units" msgstr "Org Units" @@ -407,21 +422,6 @@ 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." -msgid "Program is required" -msgstr "Program is required" - -msgid "Program stage is required" -msgstr "Program stage is required" - -msgid "No organisation units are selected." -msgstr "No organisation units are selected." - -msgid "No legend set is selected" -msgstr "No legend set is selected" - -msgid "Isolated class max should be greater than min" -msgstr "Isolated class max should be greater than min" - msgid "Event status" msgstr "Event status" diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 20c6e0845c..a2622e5b1c 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -310,8 +310,10 @@ const EventDialog = ({ programStage={programStage} value={eventCoordinateField} type={eventCoordinateFieldType} - onChange={(val) => - dispatch(setEventCoordinateField(val)) + onChange={(fieldId, fieldType) => + dispatch( + setEventCoordinateField(fieldId, fieldType) + ) } className={styles.select} data-test="eventdialog-coordinatefield" From befc3afd1ab748e25f972cfac53c630bcacf5104 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Fri, 26 Jun 2026 16:49:59 +0200 Subject: [PATCH 04/26] fix: prevent duplicate datastore requests on load (#3696) --- .../app/__tests__/useLoadDataStore.spec.js | 153 ++++++++++++++++++ src/components/app/useLoadDataStore.js | 147 +++++++++-------- 2 files changed, 231 insertions(+), 69 deletions(-) create mode 100644 src/components/app/__tests__/useLoadDataStore.spec.js diff --git a/src/components/app/__tests__/useLoadDataStore.spec.js b/src/components/app/__tests__/useLoadDataStore.spec.js new file mode 100644 index 0000000000..6f730b3500 --- /dev/null +++ b/src/components/app/__tests__/useLoadDataStore.spec.js @@ -0,0 +1,153 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { initLayerSources } from '../../../actions/layerSources.js' +import { + earthEngineLayersIds, + earthEngineLayersDefaultIds, + earthEngineLayersUpdates, +} from '../../../constants/earthEngineLayers/index.js' +import { MAPS_APP_NAMESPACE } from '../../../constants/settings.js' +import { useLoadDataStore } from '../useLoadDataStore.js' + +const mockDispatch = jest.fn() +let mockEngine + +jest.mock('react-redux', () => ({ + useDispatch: () => mockDispatch, +})) + +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: () => mockEngine, +})) + +const defaultIds = earthEngineLayersDefaultIds() + +// Build an engine mock that answers the two distinct queries the hook makes: +// the top-level `dataStore` namespace list, and the managed layer sources key. +const setupEngine = ({ + namespaces = [], + visibility, + visibilityRejects, +} = {}) => { + mockEngine = { + query: jest.fn((q) => { + if ('dataStore' in q) { + return Promise.resolve({ dataStore: namespaces }) + } + if ('layerSourcesVisibility' in q) { + return visibilityRejects + ? Promise.reject(new Error('404')) + : Promise.resolve({ layerSourcesVisibility: visibility }) + } + return Promise.resolve({}) + }), + mutate: jest.fn().mockResolvedValue({}), + } +} + +describe('useLoadDataStore', () => { + beforeEach(() => { + mockDispatch.mockClear() + }) + + it('queries the datastore once on mount, not on every render', async () => { + setupEngine({ namespaces: [] }) + const { rerender } = renderHook(() => useLoadDataStore()) + + // Force several re-renders, as App does on any redux state change + rerender() + rerender() + rerender() + + // The regression: before the fix this fired on every render. + expect(mockEngine.query).toHaveBeenCalledTimes(1) + expect(mockEngine.query).toHaveBeenCalledWith({ + dataStore: { resource: 'dataStore' }, + }) + }) + + it('creates the namespace key once with defaults when the namespace is missing', async () => { + setupEngine({ namespaces: ['SOME_OTHER_APP'] }) + renderHook(() => useLoadDataStore()) + + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith( + initLayerSources(defaultIds) + ) + ) + expect(mockEngine.mutate).toHaveBeenCalledTimes(1) + expect(mockEngine.mutate).toHaveBeenCalledWith( + expect.objectContaining({ type: 'create', data: defaultIds }) + ) + }) + + it('dispatches stored ids without mutating when there are no updates', async () => { + const stored = earthEngineLayersDefaultIds() + const expectedValidIds = earthEngineLayersIds().filter((id) => + stored.includes(id) + ) + setupEngine({ namespaces: [MAPS_APP_NAMESPACE], visibility: stored }) + renderHook(() => useLoadDataStore()) + + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith( + initLayerSources(expectedValidIds) + ) + ) + expect(mockEngine.mutate).not.toHaveBeenCalled() + }) + + it('migrates and persists ids when stored ids are outdated', async () => { + const stored = Object.keys(earthEngineLayersUpdates) + const updatedIds = stored.map( + (id) => earthEngineLayersUpdates[id] || id + ) + const expectedValidIds = earthEngineLayersIds().filter((id) => + updatedIds.includes(id) + ) + setupEngine({ namespaces: [MAPS_APP_NAMESPACE], visibility: stored }) + renderHook(() => useLoadDataStore()) + + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith( + initLayerSources(expectedValidIds) + ) + ) + expect(mockEngine.mutate).toHaveBeenCalledTimes(1) + expect(mockEngine.mutate).toHaveBeenCalledWith( + expect.objectContaining({ type: 'update', data: expectedValidIds }) + ) + }) + + it('resets the key to defaults when stored value is not an array', async () => { + setupEngine({ namespaces: [MAPS_APP_NAMESPACE], visibility: {} }) + renderHook(() => useLoadDataStore()) + + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith( + initLayerSources(defaultIds) + ) + ) + expect(mockEngine.mutate).toHaveBeenCalledTimes(1) + expect(mockEngine.mutate).toHaveBeenCalledWith( + expect.objectContaining({ type: 'update', data: defaultIds }) + ) + }) + + it('creates the key with defaults when the key query rejects', async () => { + setupEngine({ + namespaces: [MAPS_APP_NAMESPACE], + visibilityRejects: true, + }) + renderHook(() => useLoadDataStore()) + + await waitFor(() => + expect(mockDispatch).toHaveBeenCalledWith( + initLayerSources(defaultIds) + ) + ) + expect(mockEngine.mutate).toHaveBeenCalledTimes(1) + expect(mockEngine.mutate).toHaveBeenCalledWith( + expect.objectContaining({ type: 'create', data: defaultIds }) + ) + }) +}) diff --git a/src/components/app/useLoadDataStore.js b/src/components/app/useLoadDataStore.js index 6a5569c1ad..2a22d94f3a 100644 --- a/src/components/app/useLoadDataStore.js +++ b/src/components/app/useLoadDataStore.js @@ -1,4 +1,6 @@ import { useDataEngine } from '@dhis2/app-runtime' +import log from 'loglevel' +import { useEffect } from 'react' import { useDispatch } from 'react-redux' import { initLayerSources } from '../../actions/layerSources.js' import { @@ -28,78 +30,85 @@ export const useLoadDataStore = () => { const layerSourceDefaultIds = [...earthEngineLayersDefaultIds()] const dispatch = useDispatch() const engine = useDataEngine() - engine - .query({ dataStore: { resource: 'dataStore' } }) - .then(({ dataStore }) => { + + useEffect(() => { + // Guard against dispatching/mutating after unmount (and against + // React 18 StrictMode running the effect twice in development) + let cancelled = false + + // Write the default layer sources to the key and load them + const resetToDefaults = async (type) => { + await engine.mutate({ + resource: resourceLayerSourcesVisibility, + type, + data: layerSourceDefaultIds, + }) + if (!cancelled) { + dispatch(initLayerSources(layerSourceDefaultIds)) + } + } + + const loadDataStore = async () => { + const { dataStore } = await engine.query({ + dataStore: { resource: 'dataStore' }, + }) + + if (cancelled) { + return + } + if (!dataStore.includes(MAPS_APP_NAMESPACE)) { // Create namespace/key if missing in datastore - engine - .mutate({ + await resetToDefaults('create') + return + } + + try { + const { layerSourcesVisibility } = await engine.query({ + layerSourcesVisibility: { resource: resourceLayerSourcesVisibility, - type: 'create', - data: layerSourceDefaultIds, - }) - .then(() => { - dispatch(initLayerSources(layerSourceDefaultIds)) - }) - } else { - engine - .query({ - layerSourcesVisibility: { - resource: resourceLayerSourcesVisibility, - }, - }) - .then(({ layerSourcesVisibility }) => { - if (!Array.isArray(layerSourcesVisibility)) { - // Reset namespace/key if integrity has been broken - engine - .mutate({ - resource: resourceLayerSourcesVisibility, - type: 'update', - data: layerSourceDefaultIds, - }) - .then(() => { - dispatch( - initLayerSources(layerSourceDefaultIds) - ) - }) - } else { - const { ids, changed } = applyUpdates( - layerSourcesVisibility - ) - const validIds = earthEngineLayersIds().filter( - (id) => ids.includes(id) - ) - if (changed) { - engine - .mutate({ - resource: - resourceLayerSourcesVisibility, - type: 'update', - data: validIds, - }) - .then(() => { - dispatch(initLayerSources(validIds)) - }) - } else { - dispatch(initLayerSources(validIds)) - } - } - }) - .catch(() => { - // Create key if missing in namespace - engine - .mutate({ - resource: resourceLayerSourcesVisibility, - type: 'create', - data: layerSourceDefaultIds, - }) - .then(() => { - dispatch( - initLayerSources(layerSourceDefaultIds) - ) - }) + }, + }) + + if (cancelled) { + return + } + + if (!Array.isArray(layerSourcesVisibility)) { + // Reset namespace/key if integrity has been broken + await resetToDefaults('update') + return + } + + const { ids, changed } = applyUpdates(layerSourcesVisibility) + const validIds = earthEngineLayersIds().filter((id) => + ids.includes(id) + ) + if (changed) { + await engine.mutate({ + resource: resourceLayerSourcesVisibility, + type: 'update', + data: validIds, }) + } + if (!cancelled) { + dispatch(initLayerSources(validIds)) + } + } catch { + if (!cancelled) { + // Create key if missing in namespace + await resetToDefaults('create') + } } - }) + } + + loadDataStore().catch((error) => + log.error('Failed to load managed layer sources', error) + ) + + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) } From 357cc53d2ab130b3b53c297ef2cb1baeb7b96c8d Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Fri, 26 Jun 2026 17:33:13 +0200 Subject: [PATCH 05/26] fix: remove north arrow rotate listener on cleanup (#3698) --- src/components/download/NorthArrow.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/download/NorthArrow.jsx b/src/components/download/NorthArrow.jsx index 54dcb12382..95d49c244f 100644 --- a/src/components/download/NorthArrow.jsx +++ b/src/components/download/NorthArrow.jsx @@ -25,7 +25,7 @@ const NorthArrow = ({ useEffect(() => { map.on('rotate', onMapRotate) return () => { - map.on('rotate', onMapRotate) + map.off('rotate', onMapRotate) } }, [map, onMapRotate]) From 36f8f315d063a20d8a0fdadbbbafeefa2158f444 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Fri, 26 Jun 2026 18:12:33 +0200 Subject: [PATCH 06/26] fix: prevent data table from crashing when cursor leaves browser window (#3699) --- src/components/datatable/DataTable.jsx | 15 +++++++---- .../datatable/__tests__/DataTable.spec.jsx | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 src/components/datatable/__tests__/DataTable.spec.jsx diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 26598f7999..3d92479c39 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -35,6 +35,15 @@ import { useTableData } from './useTableData.js' const ASCENDING = 'asc' const DESCENDING = 'desc' +// Decides whether a row's highlight should be cleared on mouse leave. +// When hovering to the next row the next element is a `TD`, in which case +// `setFeatureHighlight` fires and the highlight does not need to be cleared. +// When leaving to no element (e.g. the cursor exits the browser window) +// `relatedTarget` is null, so the optional chaining guards against a crash. +// Exported for testing. +export const shouldClearFeatureHighlight = (event) => + event.relatedTarget?.tagName !== 'TD' + const DataTableWithVirtuosoContext = ({ context, ...props }) => ( { ) const clearFeatureHighlight = useCallback( (event) => { - const nextElement = event.toElement ?? event.relatedTarget - // When hovering to the next row the next element is a `TD` - // If this is the case `setFeatureHighlight` will - // fire and the highlight does not need to be cleared - if (nextElement.tagName !== 'TD') { + if (shouldClearFeatureHighlight(event)) { dispatch(highlightFeature(null)) } }, diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx new file mode 100644 index 0000000000..5d18019348 --- /dev/null +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -0,0 +1,27 @@ +import { shouldClearFeatureHighlight } from '../DataTable.jsx' + +// DataTable.jsx transitively imports MapApi.js (maplibre-gl), which is not +// needed here and fails to load under jsdom. +jest.mock('../../map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +describe('shouldClearFeatureHighlight', () => { + test('clears when leaving to no element (cursor exits the window)', () => { + // Regression: relatedTarget is null on window exit, which previously + // threw `null.tagName` and crashed the table via the ErrorBoundary. + expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true) + }) + + test('does not clear when hovering to an adjacent row cell (TD)', () => { + expect( + shouldClearFeatureHighlight({ relatedTarget: { tagName: 'TD' } }) + ).toBe(false) + }) + + test('clears when leaving to a non-TD element', () => { + expect( + shouldClearFeatureHighlight({ relatedTarget: { tagName: 'DIV' } }) + ).toBe(true) + }) +}) From f57e515a5be1d8a251a802415233417ec3711c32 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Fri, 26 Jun 2026 18:23:06 +0200 Subject: [PATCH 07/26] fix: correct time period filtering in Earth Engine layers (#3697) --- d2.config.js | 44 ++++++++++ package.json | 2 +- .../__tests__/earthEngineLoader.spec.js | 84 ++++++++++++++++++- src/loaders/earthEngineLoader.js | 5 +- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/d2.config.js b/d2.config.js index c15ba18784..151bde1fc4 100644 --- a/d2.config.js +++ b/d2.config.js @@ -1,3 +1,5 @@ +const fs = require('node:fs') + const omitPatterns = [ // User info 'me/authorization', @@ -47,7 +49,49 @@ const config = { }, viteConfigExtensions: { + plugins: [ + { + // Wraps the CJS Google EE API bundle as ESM so Vite doesn't + // flag it as an unoptimised dep and trigger a full-reload when + // the EE worker first imports it. + name: 'ee-api-js-worker-esm', + apply: 'serve', + load(id) { + if ( + id.endsWith('/ee_api_js_worker.js') && + !id.includes('?') + ) { + const content = fs.readFileSync(id, 'utf-8') + return { + code: + 'const module={exports:{}};const exports=module.exports;\n' + + content + + '\nexport default module.exports;', + map: null, + } + } + }, + }, + ], optimizeDeps: { + // Excluded so Vite serves maps-gl via /@fs/... with its full + // transform pipeline, which lets the EE worker URL resolve to the + // actual source file (rather than /.vite/earthengine/... where + // bare imports are not rewritten). + exclude: ['@dhis2/maps-gl'], + // maps-gl's CJS deps must be listed explicitly; Vite's scanner + // won't traverse an excluded package to discover them. + include: [ + 'maplibre-gl', + 'fetch-jsonp', + 'lodash.throttle', + '@mapbox/sphericalmercator', + '@turf/jsts', + 'concaveman', + 'polylabel', + 'pretty-bytes', + 'suggestions', + ], esbuildOptions: { target: 'es2022', }, diff --git a/package.json b/package.json index 7a127cd893..021f013321 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "private": true, "scripts": { "build": "d2-app-scripts build", - "start": "node scripts/copy-ee-worker-files.js && d2-app-scripts start", + "start": "d2-app-scripts start", "test": "d2-app-scripts test", "deploy": "d2-app-scripts deploy", "lint": "d2-style check", diff --git a/src/loaders/__tests__/earthEngineLoader.spec.js b/src/loaders/__tests__/earthEngineLoader.spec.js index bde8c35ea1..078203b6ae 100644 --- a/src/loaders/__tests__/earthEngineLoader.spec.js +++ b/src/loaders/__tests__/earthEngineLoader.spec.js @@ -3,12 +3,94 @@ import { DIGIT_GROUP_SEPARATOR_NONE, DIGIT_GROUP_SEPARATOR_SPACE, } from '../../constants/settings.js' -import { createLegend } from '../earthEngineLoader.js' +import earthEngineLoader, { createLegend } from '../earthEngineLoader.js' jest.mock('../../components/map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) +describe('earthEngineLoader', () => { + // No org units, so the loader skips the engine query entirely. + const baseArgs = { + engine: { query: jest.fn() }, + keyAnalysisDisplayProperty: 'name', + keyAnalysisDigitGroupSeparator: DIGIT_GROUP_SEPARATOR_COMMA, + userId: 'userId', + } + + describe('backward compatibility for layers saved before v100.6.0', () => { + // Pre-v100.6.0 favorites stored a dynamic `filter` array (and no + // `image`), which the loader converts into a `period`. + const configWithFilter = (filter) => ({ + ...baseArgs, + config: { + rows: [], + config: JSON.stringify({ + id: 'fakeDatasetId', + filter, + }), + }, + }) + + it('loads a filter-only layer config without throwing', async () => { + // Regression: `filter` was referenced out of scope in this branch, + // throwing a ReferenceError outside the try/catch so the loader + // promise rejected and the layer never loaded (permanent spinner). + const result = await earthEngineLoader( + configWithFilter([ + { + id: '2020', + name: '2020', + year: 2020, + arguments: ['year', '2020'], + }, + ]) + ) + + expect(result.isLoaded).toBe(true) + expect(result.period).toEqual({ + id: '2020', + name: '2020', + year: 2020, + }) + }) + + it('derives the period id from the filter arguments when no id is set', async () => { + const result = await earthEngineLoader( + configWithFilter([{ arguments: ['year', '2015'] }]) + ) + + expect(result.isLoaded).toBe(true) + expect(result.period.id).toBe('2015') + }) + }) + + describe('backward compatibility for layers with periods saved before 2.36', () => { + // Pre-2.36 favorites stored both an `image` and a dynamic `filter`, + // from which the loader reconstructs the `period`. + it('builds the period from the image and filter', async () => { + const result = await earthEngineLoader({ + ...baseArgs, + config: { + rows: [], + config: JSON.stringify({ + id: 'fakeDatasetId', + image: 'WorldPop', + filter: [{ arguments: ['system:index', '20200101'] }], + }), + }, + }) + + expect(result.isLoaded).toBe(true) + expect(result.period).toEqual({ + id: '20200101', + name: 'WorldPop', + year: 2020, + }) + }) + }) +}) + describe('createLegend', () => { describe('when ranges are provided', () => { it('maps palette colors to ranges and ignores separator', () => { diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index f931eaa324..402892890a 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -66,7 +66,10 @@ const earthEngineLoader = async ({ delete layerConfig.image } else if (layerConfig.filter) { // Backward compability for layers saved before v100.6.0 - layerConfig.period = getPeriodFromFilter(filter, layerConfig.id) + layerConfig.period = getPeriodFromFilter( + layerConfig.filter, + layerConfig.id + ) delete layerConfig.filter } From 1a3f26723482e93cea338189424621a630a258f1 Mon Sep 17 00:00:00 2001 From: Karoline Tufte Lien Date: Fri, 26 Jun 2026 18:34:50 +0200 Subject: [PATCH 08/26] fix: prevent crash when loading map with external layer (#3700) --- .../__tests__/getMigratedMapConfig.spec.js | 40 +++++++++++++++++++ src/util/getMigratedMapConfig.js | 17 +++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/util/__tests__/getMigratedMapConfig.spec.js b/src/util/__tests__/getMigratedMapConfig.spec.js index 1b8d2ee0c0..0cb58ed5d6 100644 --- a/src/util/__tests__/getMigratedMapConfig.spec.js +++ b/src/util/__tests__/getMigratedMapConfig.spec.js @@ -48,6 +48,46 @@ test('getMigratedMapConfig when basemap in mapViews', () => { ) }) +test.each([ + ['empty string config', ''], + ['missing config', undefined], + ['null config', null], + ['malformed config string', '{not valid json'], +])( + 'getMigratedMapConfig does not throw for external layer with %s', + (_label, externalConfig) => { + const config = { + id: 'mapId', + name: 'map name', + mapViews: [ + { layer: 'external', config: externalConfig }, + { layer: 'thematic', name: 'All the pretty colors' }, + ], + } + + let result + expect(() => { + result = getMigratedMapConfig(config, defaultBasemapId) + }).not.toThrow() + + // The external view is not treated as a basemap, so it is retained + // and the default basemap is applied. + expect(result.basemap).toEqual({ + id: defaultBasemapId, + opacity: 1, + isVisible: true, + }) + expect(result.mapViews).toEqual([ + { layer: 'external', config: externalConfig, isVisible: true }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + ]) + } +) + test('getMigratedMapConfig when basemap is a string but not "none"', () => { const config = { id: 'mapId', diff --git a/src/util/getMigratedMapConfig.js b/src/util/getMigratedMapConfig.js index 65417a87a0..ef289db098 100644 --- a/src/util/getMigratedMapConfig.js +++ b/src/util/getMigratedMapConfig.js @@ -6,18 +6,29 @@ export const getMigratedMapConfig = (config, defaultBasemapId) => upgradeGisAppLayers(extractBasemap(config, defaultBasemapId)) ) +// Safely parse a mapView config, which may be empty, missing or malformed +const safeParseConfig = (config) => { + try { + // Guard against JSON.parse returning non-objects (null, numbers) + const parsed = JSON.parse(config) + return isObject(parsed) ? parsed : {} + } catch { + return {} + } +} + // Different ways of specifying a basemap const extractBasemap = (config, defaultBasemapId) => { const externalBasemap = config.mapViews.find( (view) => view.layer === EXTERNAL_LAYER && - JSON.parse(view.config || {}).mapLayerPosition === 'BASEMAP' + safeParseConfig(view.config).mapLayerPosition === 'BASEMAP' ) let basemap let mapViews = config.mapViews if (externalBasemap) { - basemap = JSON.parse(externalBasemap.config) + basemap = safeParseConfig(externalBasemap.config) mapViews = config.mapViews.filter( (view) => view.id !== externalBasemap.id ) @@ -30,6 +41,8 @@ const extractBasemap = (config, defaultBasemapId) => { basemap = { isVisible: false } } else { try { + // Not safeParseConfig: this branch recovers a malformed value + // as a plain basemap ID rather than falling back to {} const { id, opacity, hidden } = JSON.parse(config.basemap) basemap = { id, opacity, isVisible: !hidden } } catch { From 7ec0beb259763376ee01e4093d85fd2694776794 Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Mon, 29 Jun 2026 08:02:44 +0000 Subject: [PATCH 09/26] chore(release): cut 101.13.4 [skip release] ## [101.13.4](https://github.com/dhis2/maps-app/compare/v101.13.3...v101.13.4) (2026-06-29) ### Bug Fixes * correct time period filtering in Earth Engine layers ([#3697](https://github.com/dhis2/maps-app/issues/3697)) ([c8d7367](https://github.com/dhis2/maps-app/commit/c8d736716a832a005ce67a819b88d740dc90738d)) * prevent crash when loading map with external layer ([#3700](https://github.com/dhis2/maps-app/issues/3700)) ([79777d3](https://github.com/dhis2/maps-app/commit/79777d3b42f45af5a4bc069a021902b51d4c4f58)) * prevent data table from crashing when cursor leaves browser window ([#3699](https://github.com/dhis2/maps-app/issues/3699)) ([5ccb6b4](https://github.com/dhis2/maps-app/commit/5ccb6b415595991869cf0b59031e6958e150c4b5)) * prevent duplicate datastore requests on load ([#3696](https://github.com/dhis2/maps-app/issues/3696)) ([b097e01](https://github.com/dhis2/maps-app/commit/b097e0108bd84edb84ee9e03d9213fe7c9b3d5ae)) * remove north arrow rotate listener on cleanup ([#3698](https://github.com/dhis2/maps-app/issues/3698)) ([b7d1859](https://github.com/dhis2/maps-app/commit/b7d1859f7dcb4c1a7436be1f9a19fb80a702a5a3)) --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a619623ec5..e44de8f76b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [101.13.4](https://github.com/dhis2/maps-app/compare/v101.13.3...v101.13.4) (2026-06-29) + + +### Bug Fixes + +* correct time period filtering in Earth Engine layers ([#3697](https://github.com/dhis2/maps-app/issues/3697)) ([c8d7367](https://github.com/dhis2/maps-app/commit/c8d736716a832a005ce67a819b88d740dc90738d)) +* prevent crash when loading map with external layer ([#3700](https://github.com/dhis2/maps-app/issues/3700)) ([79777d3](https://github.com/dhis2/maps-app/commit/79777d3b42f45af5a4bc069a021902b51d4c4f58)) +* prevent data table from crashing when cursor leaves browser window ([#3699](https://github.com/dhis2/maps-app/issues/3699)) ([5ccb6b4](https://github.com/dhis2/maps-app/commit/5ccb6b415595991869cf0b59031e6958e150c4b5)) +* prevent duplicate datastore requests on load ([#3696](https://github.com/dhis2/maps-app/issues/3696)) ([b097e01](https://github.com/dhis2/maps-app/commit/b097e0108bd84edb84ee9e03d9213fe7c9b3d5ae)) +* remove north arrow rotate listener on cleanup ([#3698](https://github.com/dhis2/maps-app/issues/3698)) ([b7d1859](https://github.com/dhis2/maps-app/commit/b7d1859f7dcb4c1a7436be1f9a19fb80a702a5a3)) + ## [101.13.3](https://github.com/dhis2/maps-app/compare/v101.13.2...v101.13.3) (2026-06-23) diff --git a/package.json b/package.json index 021f013321..605fa4a1da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "maps-app", - "version": "101.13.3", + "version": "101.13.4", "description": "DHIS2 Maps", "license": "BSD-3-Clause", "author": "Bjørn Sandvik", From a65ae3624e52e64beb0e8586b0b852209e6c5162 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:52:28 +0200 Subject: [PATCH 10/26] chore(deps): bump the dependencies group across 1 directory with 2 updates (#3703) Bumps the dependencies group with 2 updates in the / directory: [query-string](https://github.com/sindresorhus/query-string) and [simple-statistics](https://github.com/simple-statistics/simple-statistics). Updates `query-string` from 9.4.0 to 9.4.1 - [Release notes](https://github.com/sindresorhus/query-string/releases) - [Commits](https://github.com/sindresorhus/query-string/compare/v9.4.0...v9.4.1) Updates `simple-statistics` from 7.9.0 to 7.9.1 - [Release notes](https://github.com/simple-statistics/simple-statistics/releases) - [Changelog](https://github.com/simple-statistics/simple-statistics/blob/main/CHANGELOG.md) - [Commits](https://github.com/simple-statistics/simple-statistics/compare/v7.9.0...v7.9.1) --- updated-dependencies: - dependency-name: query-string dependency-version: 9.4.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies - dependency-name: simple-statistics dependency-version: 7.9.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 4 ++-- yarn.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 605fa4a1da..e611c7ca2f 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "lodash": "^4.18.1", "loglevel": "^1.9.2", "prop-types": "^15.8.1", - "query-string": "^9.4.0", + "query-string": "^9.4.1", "react-redux": "^9.3.0", "react-sortable-hoc": "^1.11.0", "react-virtualized": "^9.22.6", @@ -75,7 +75,7 @@ "redux": "^4.2.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.4.2", - "simple-statistics": "^7.9.0", + "simple-statistics": "^7.9.1", "styled-jsx": "^4.0.1", "url-polyfill": "^1.1.14" }, diff --git a/yarn.lock b/yarn.lock index aeca07d852..a8bc71692a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12851,10 +12851,10 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== -query-string@^9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.4.0.tgz#4f2cd518680e087dd781dd605f75dcb18c7f7a53" - integrity sha512-ivvWyHqU9K1Log4hJFhqVIIMoEi0nzmlRhvk2pPcTuQH/Y0K5iTTMxEx7R0PRHD2Z1hMVbWnjfsEWbIKIK+3IA== +query-string@^9.4.1: + version "9.4.1" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.4.1.tgz#fc5b1cc5c1944eaa1c8e6d4912b5bb90dc66fa2d" + integrity sha512-lSyJeN3RuaG7DZGWThtYRhk96+kEyZ/+doZpERuWbjeFL+Ok3vEat/swU498rAI0NcVt5/RJp8UDuLz7FckxrA== dependencies: decode-uri-component "^0.4.1" filter-obj "^5.1.0" @@ -13952,10 +13952,10 @@ simple-concat@^1.0.0: resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== -simple-statistics@^7.9.0: - version "7.9.0" - resolved "https://registry.yarnpkg.com/simple-statistics/-/simple-statistics-7.9.0.tgz#ff14bbf877e838c7a460ad6e3f00b2d6928b5d7f" - integrity sha512-OOF4uUZseYAC54r2/W58KxlIe4aA33GyPBrX4WMSxQq/NBNVNIOBlJerpGnb64jGH6cUIqKKOkMdhymtmKmpiA== +simple-statistics@^7.9.1: + version "7.9.1" + resolved "https://registry.yarnpkg.com/simple-statistics/-/simple-statistics-7.9.1.tgz#c3e1b6e71f8744f6e46c54e800c24a7500870ef1" + integrity sha512-F2KqbV8mNYuARZdIuKtqv8Ax0s5KLKy9kEsaAFnKJxkTU+ZjmBae5ssp6mtPqpAj+D29OITOSbm9aiSkEPo1cA== slash@^2.0.0: version "2.0.0" From 4d5292466c8c7388a19571b141427ef2f49dea0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:29:40 +0200 Subject: [PATCH 11/26] chore(deps): bump react-sortable-hoc from 1.11.0 to 2.0.0 (#3704) Bumps [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc) from 1.11.0 to 2.0.0. - [Changelog](https://github.com/clauderic/react-sortable-hoc/blob/master/CHANGELOG.md) - [Commits](https://github.com/clauderic/react-sortable-hoc/commits) --- updated-dependencies: - dependency-name: react-sortable-hoc dependency-version: 2.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Bruno Raimbault --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e611c7ca2f..dcb782430f 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "prop-types": "^15.8.1", "query-string": "^9.4.1", "react-redux": "^9.3.0", - "react-sortable-hoc": "^1.11.0", + "react-sortable-hoc": "^2.0.0", "react-virtualized": "^9.22.6", "react-virtuoso": "^4.6.2", "redux": "^4.2.1", diff --git a/yarn.lock b/yarn.lock index a8bc71692a..ce635fa16e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13020,10 +13020,10 @@ react-refresh@^0.17.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== -react-sortable-hoc@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-1.11.0.tgz#fe4022362bbafc4b836f5104b9676608a40a278f" - integrity sha512-v1CDCvdfoR3zLGNp6qsBa4J1BWMEVH25+UKxF/RvQRh+mrB+emqtVHMgZ+WreUiKJoEaiwYoScaueIKhMVBHUg== +react-sortable-hoc@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz#f6780d8aa4b922a21f3e754af542f032677078b7" + integrity sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg== dependencies: "@babel/runtime" "^7.2.0" invariant "^2.2.4" From eaa71c26c20ce575d3bcf5f44a6057c008c4e4b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:27:09 +0200 Subject: [PATCH 12/26] chore(deps): bump @dhis2/analytics from 29.4.1 to 29.5.4 (#3706) Bumps [@dhis2/analytics](https://github.com/dhis2/analytics) from 29.4.1 to 29.5.4. - [Release notes](https://github.com/dhis2/analytics/releases) - [Changelog](https://github.com/dhis2/analytics/blob/master/CHANGELOG.md) - [Commits](https://github.com/dhis2/analytics/compare/v29.4.1...v29.5.4) --- updated-dependencies: - dependency-name: "@dhis2/analytics" dependency-version: 29.5.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Bruno Raimbault --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index dcb782430f..367fd7cae2 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "start-server-and-test": "^2.1.3" }, "dependencies": { - "@dhis2/analytics": "^29.4.1", + "@dhis2/analytics": "^29.5.4", "@dhis2/app-runtime": "^3.14.4", "@dhis2/app-service-datastore": "^1.0.0-beta.3", "@dhis2/maps-gl": "^4.2.8", diff --git a/yarn.lock b/yarn.lock index ce635fa16e..2f7db8ea89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2144,10 +2144,10 @@ classnames "^2.3.1" prop-types "^15.7.2" -"@dhis2/analytics@^29.4.1": - version "29.4.1" - resolved "https://registry.yarnpkg.com/@dhis2/analytics/-/analytics-29.4.1.tgz#89fc5b895944b237c5bf5d7af155859a6dc71d2b" - integrity sha512-yf4m+jX3vAsJIgYYqX4cJXmzwtFFjn0oE81l6P169VrXAWVXUhEmI1pp9bd1Hj/GEjYB0XQ6b6yIrNZPVuZK0g== +"@dhis2/analytics@^29.5.4": + version "29.5.4" + resolved "https://registry.yarnpkg.com/@dhis2/analytics/-/analytics-29.5.4.tgz#e7dc9a11f634c4ee50d0866aacc801b31b621808" + integrity sha512-qQj4rLLdJLJLTp6ByG2P/XAtjeZq/5AquQErZR4CCe474vVcr4UVQ+h2rsRqHBj9EpYUAXr5kkCD5mQbPXqthQ== dependencies: "@dhis2/multi-calendar-dates" "^1.2.2" "@dnd-kit/core" "^6.0.7" From dc0e02aa27abb70db177b2831c9e5bda91fd0492 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 29 Jun 2026 16:10:12 +0200 Subject: [PATCH 13/26] feat: add style by geometry source for event layers [DHIS2-8165] --- cypress/support/requests.js | 2 +- i18n/en.pot | 17 ++- src/actions/layerEdit.js | 6 + src/components/dataItem/CoordinateField.jsx | 32 ++--- src/components/dataItem/DataItemStyle.jsx | 19 +++ .../dataItem/GeometrySourceStyle.jsx | 132 ++++++++++++++++++ src/components/dataItem/StyleByDataItem.jsx | 8 +- src/components/edit/event/EventDialog.jsx | 27 ++-- src/components/map/layers/EventPopup.jsx | 25 ++++ src/constants/actionTypes.js | 2 + src/constants/layers.js | 2 + src/constants/settings.js | 1 + src/reducers/layerEdit.js | 12 ++ src/util/styleByDataItem.js | 47 ++++++- 14 files changed, 294 insertions(+), 38 deletions(-) create mode 100644 src/components/dataItem/GeometrySourceStyle.jsx diff --git a/cypress/support/requests.js b/cypress/support/requests.js index a4df8b94bc..55ab5b33e1 100644 --- a/cypress/support/requests.js +++ b/cypress/support/requests.js @@ -64,7 +64,7 @@ const requests = { }, getCachedDataProvider_SystemSettings: { method: 'GET', - url: '**/systemSettings?key=keyAnalysisRelativePeriod,keyHideDailyPeriods,keyHideWeeklyPeriods,keyHideBiWeeklyPeriods,keyHideMonthlyPeriods,keyHideBiMonthlyPeriods,keyDefaultBaseMap,keyBingMapsApiKey,keyAzureMapsApiKey', + url: '**/systemSettings?key=keyAnalysisRelativePeriod,keyHideDailyPeriods,keyHideWeeklyPeriods,keyHideBiWeeklyPeriods,keyHideMonthlyPeriods,keyHideBiMonthlyPeriods,keyDefaultBaseMap,orgUnitCentroidsInEventsAnalytics,keyBingMapsApiKey,keyAzureMapsApiKey', }, getCachedDataProvider_ExternalMapLayers: { method: 'GET', diff --git a/i18n/en.pot b/i18n/en.pot index f26c741411..a36e848ca4 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-06-26T13:35:21.967Z\n" -"PO-Revision-Date: 2026-06-26T13:35:21.967Z\n" +"POT-Creation-Date: 2026-06-29T14:10:13.913Z\n" +"PO-Revision-Date: 2026-06-29T14:10:13.913Z\n" msgid "2020" msgstr "2020" @@ -101,15 +101,15 @@ msgstr "Enrollment location" msgid "Tracked entity location" msgstr "Tracked entity location" -msgid "Select a program stage to see additional coordinate options" -msgstr "Select a program stage to see additional coordinate options" - msgid "Enrollment > event > tracked entity > org unit coordinate" msgstr "Enrollment > event > tracked entity > org unit coordinate" msgid "Event > org unit coordinate" msgstr "Event > org unit coordinate" +msgid "Select a program stage to see additional coordinate options" +msgstr "Select a program stage to see additional coordinate options" + msgid "Select a program to see additional coordinate options" msgstr "Select a program to see additional coordinate options" @@ -119,11 +119,14 @@ msgstr "Fallback coordinate field" msgid "Coordinate field" msgstr "Coordinate field" +msgid "Include events with no data" +msgstr "Include events with no data" + msgid "Include unclassified events" msgstr "Include unclassified events" -msgid "Include events with no data" -msgstr "Include events with no data" +msgid "Geometry source" +msgstr "Geometry source" msgid "Previously selected value not available in list: {{id}}" msgstr "Previously selected value not available in list: {{id}}" diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 23a0d13daf..7f634eb27c 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -75,6 +75,12 @@ export const setBooleanStyle = (value, color) => ({ color, }) +export const setGeometrySourceStyle = (value, color) => ({ + type: types.LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET, + value, + color, +}) + // Set thematic map type (choropleth, bubble map) export const setThematicMapType = (type) => ({ type: types.LAYER_EDIT_THEMATIC_MAP_TYPE_SET, diff --git a/src/components/dataItem/CoordinateField.jsx b/src/components/dataItem/CoordinateField.jsx index 1bce777c4c..8497e00a6f 100644 --- a/src/components/dataItem/CoordinateField.jsx +++ b/src/components/dataItem/CoordinateField.jsx @@ -21,6 +21,7 @@ const CoordinateField = ({ eventCoordinateField, onChange, className, + dataTest = 'coordinatefield', }) => { const { serverVersion } = useConfig() @@ -48,7 +49,10 @@ const CoordinateField = ({ includeTypes, }) - const defaultValue = eventCoordinateField ? NONE : EVENT_COORDINATE_DEFAULT + const defaultValue = useMemo( + () => (eventCoordinateField ? NONE : EVENT_COORDINATE_DEFAULT), + [eventCoordinateField] + ) const fields = useMemo(() => { const isFallback = !!eventCoordinateField @@ -93,30 +97,23 @@ const CoordinateField = ({ fields.push(...eventDataItems) } - if (isFallback) { - fields.push({ - id: EVENT_COORDINATE_ORG_UNIT, - name: i18n.t('Organisation unit location'), - }) - } - - return eventCoordinateField + return isFallback ? fields.filter((f) => f.id !== eventCoordinateField) : fields }, [trackedEntityType, eventDataItems, eventCoordinateField]) let helpText = null if (program) { - if (!programStage && trackedEntityType) { - helpText = i18n.t( - 'Select a program stage to see additional coordinate options' - ) - } else if (value === EVENT_COORDINATE_CASCADING) { + if (value === EVENT_COORDINATE_CASCADING) { helpText = trackedEntityType ? i18n.t( 'Enrollment > event > tracked entity > org unit coordinate' ) : i18n.t('Event > org unit coordinate') + } else if (!programStage && trackedEntityType) { + helpText = i18n.t( + 'Select a program stage to see additional coordinate options' + ) } } else { helpText = i18n.t( @@ -139,7 +136,9 @@ const CoordinateField = ({ if ( trackedEntityType && eventDataItems && - !fields.find((f) => f.id === value) + !fields.find((f) => f.id === value) && + value !== defaultValue && + fields.length > 0 ) { onChange(defaultValue, defaultValue) } @@ -169,7 +168,7 @@ const CoordinateField = ({ onChange(field.id, field.valueType || field.id) } className={className} - dataTest="coordinatefield" + dataTest={dataTest} /> ) } @@ -177,6 +176,7 @@ const CoordinateField = ({ CoordinateField.propTypes = { onChange: PropTypes.func.isRequired, className: PropTypes.string, + dataTest: PropTypes.string, eventCoordinateField: PropTypes.string, program: PropTypes.object, programStage: PropTypes.object, diff --git a/src/components/dataItem/DataItemStyle.jsx b/src/components/dataItem/DataItemStyle.jsx index dc75e11d3e..e32b39ba0e 100644 --- a/src/components/dataItem/DataItemStyle.jsx +++ b/src/components/dataItem/DataItemStyle.jsx @@ -6,6 +6,7 @@ import { setNoDataLegend, setUnclassifiedLegend, } from '../../actions/layerEdit.js' +import { GEOMETRY_SOURCE_DATA_ITEM_ID } from '../../constants/layers.js' import { numberValueTypes, booleanValueTypes, @@ -15,6 +16,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 GeometrySourceStyle from './GeometrySourceStyle.jsx' import styles from './styles/DataItemStyle.module.css' const DataItemStyle = ({ dataItem, style }) => { @@ -28,6 +30,23 @@ const DataItemStyle = ({ dataItem, style }) => { return null } + if (dataItem.id === GEOMETRY_SOURCE_DATA_ITEM_ID) { + return ( +
+ +
+
+ dispatch(setNoDataLegend(v))} + /> +
+
+
+ ) + } + const { valueType, optionSet } = dataItem const hasClassification = numberValueTypes.includes(valueType) || diff --git a/src/components/dataItem/GeometrySourceStyle.jsx b/src/components/dataItem/GeometrySourceStyle.jsx new file mode 100644 index 0000000000..21dc14836d --- /dev/null +++ b/src/components/dataItem/GeometrySourceStyle.jsx @@ -0,0 +1,132 @@ +import React, { useEffect } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { setGeometrySourceStyle } from '../../actions/layerEdit.js' +import { qualitativeColors } from '../../constants/colors.js' +import { + COORDINATE_FIELD_NAMES, + EVENT_COORDINATE_CASCADING, + EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_ENROLLMENT, + EVENT_COORDINATE_ORG_UNIT, + EVENT_COORDINATE_TRACKED_ENTITY, + NONE, +} from '../../constants/layers.js' +import { useEventDataItems } from '../../hooks/useEventDataItems.js' +import OptionStyle from '../optionSet/OptionStyle.jsx' + +const style = { + marginTop: 20, +} + +// Expands a coordinate field id to the set of geometrySource values the backend can return. +// For 'cascading', this is the full cascade chain based on whether the program has a TEI type. +const expandField = (fieldId, hasTei) => { + if (fieldId === EVENT_COORDINATE_CASCADING) { + return hasTei + ? [ + EVENT_COORDINATE_ENROLLMENT, + EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_TRACKED_ENTITY, + EVENT_COORDINATE_ORG_UNIT, + ] + : [EVENT_COORDINATE_DEFAULT, EVENT_COORDINATE_ORG_UNIT] + } + return [fieldId] +} + +const getPossibleSources = ( + eventCoordinateField, + fallbackCoordinateField, + hasTei +) => { + const main = expandField( + eventCoordinateField ?? EVENT_COORDINATE_DEFAULT, + hasTei + ) + const fallback = + fallbackCoordinateField && fallbackCoordinateField !== NONE + ? expandField(fallbackCoordinateField, hasTei) + : [] + return [...new Set([...main, ...fallback])] +} + +const GeometrySourceStyle = () => { + const dispatch = useDispatch() + const styleDataItem = useSelector((state) => state.layerEdit.styleDataItem) + const eventCoordinateField = useSelector( + (state) => state.layerEdit.eventCoordinateField + ) + const fallbackCoordinateField = useSelector( + (state) => state.layerEdit.fallbackCoordinateField + ) + const program = useSelector((state) => state.layerEdit.program) + const programStage = useSelector((state) => state.layerEdit.programStage) + + const { eventDataItems, trackedEntityType } = useEventDataItems({ + programId: program?.id, + programStageId: programStage?.id, + includeTypes: ['COORDINATE', 'ORGANISATION_UNIT'], + }) + + const hasTei = !!trackedEntityType?.id + + const sources = getPossibleSources( + eventCoordinateField, + fallbackCoordinateField, + hasTei + ) + + const values = styleDataItem?.values + + useEffect(() => { + if (eventDataItems === null) { + return + } + const usedColors = new Set(Object.values(values || {})) + const availableColors = qualitativeColors.filter( + (c) => !usedColors.has(c) + ) + let nextColorIndex = 0 + sources.forEach((sourceId) => { + if (!values?.[sourceId]) { + const color = + availableColors[nextColorIndex] ?? + qualitativeColors[nextColorIndex % qualitativeColors.length] + nextColorIndex++ + dispatch(setGeometrySourceStyle(sourceId, color)) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sources.join(','), eventDataItems === null, dispatch]) + + // Wait for event data items to load before rendering, so DE/TEA UIDs + // are never shown raw (trackedEntityType also drives sources via hasTei) + if (eventDataItems === null || !values) { + return null + } + + const resolveLabel = (sourceId) => { + if (COORDINATE_FIELD_NAMES[sourceId]) { + return COORDINATE_FIELD_NAMES[sourceId] + } + const item = eventDataItems?.find((i) => i.id === sourceId) + return item?.name ?? sourceId + } + + return ( +
+ {sources.map((sourceId) => ( + + dispatch(setGeometrySourceStyle(sourceId, color)) + } + /> + ))} +
+ ) +} + +export default GeometrySourceStyle diff --git a/src/components/dataItem/StyleByDataItem.jsx b/src/components/dataItem/StyleByDataItem.jsx index bc94658d83..0816ab7c07 100644 --- a/src/components/dataItem/StyleByDataItem.jsx +++ b/src/components/dataItem/StyleByDataItem.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types' import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { setStyleDataItem } from '../../actions/layerEdit.js' +import { GEOMETRY_SOURCE_DATA_ITEM_ID } from '../../constants/layers.js' import { useEventDataItems } from '../../hooks/useEventDataItems.js' import { SelectField } from '../core/index.js' import DataItemStyle from './DataItemStyle.jsx' @@ -15,6 +16,11 @@ const excludeTypes = [ 'COORDINATE', ] +const GEOMETRY_SOURCE_ITEM = { + id: GEOMETRY_SOURCE_DATA_ITEM_ID, + name: i18n.t('Geometry source'), +} + // Style by data item is used by event layer, and can be reused for TEI layer in the future. // Displays a select field with data items that support styling. // Styling options are shown when a data item is selected. @@ -36,7 +42,7 @@ const StyleByDataItem = ({ program, programStage, error }) => { return null } - const dataItems = [ITEM_NONE, ...eventDataItems] + const dataItems = [ITEM_NONE, GEOMETRY_SOURCE_ITEM, ...eventDataItems] const internalError = styleDataItem && !dataItems.find((item) => item.id === styleDataItem.id) diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index a2622e5b1c..bd001a84b5 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -11,7 +11,7 @@ import { setEventClustering, setEventPointColor, setEventPointRadius, - // setFallbackCoordinateField, + setFallbackCoordinateField, setPeriod, setStartDate, setEndDate, @@ -74,6 +74,7 @@ const EventDialog = ({ eventPointColor, eventPointRadius, eventStatus, + fallbackCoordinateField = 'none', filters = DEFAULT_NO_FILTERS, legendIsolated, legendSet, @@ -319,16 +320,18 @@ const EventDialog = ({ data-test="eventdialog-coordinatefield" /> - {/* eventCoordinateField && ( - - ) */} + + dispatch(setFallbackCoordinateField(fieldId)) + } + className={styles.select} + dataTest="fallbackcoordinatefield" + /> dispatch(setEventStatus(val))} @@ -493,7 +496,7 @@ EventDialog.propTypes = { eventPointColor: PropTypes.string, eventPointRadius: PropTypes.number, eventStatus: PropTypes.string, - // fallbackCoordinateField: PropTypes.string, + fallbackCoordinateField: PropTypes.string, filters: PropTypes.array, legendIsolated: PropTypes.object, legendSet: PropTypes.object, diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index a6ffca021f..d691531cf4 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -2,6 +2,10 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' +import { + COORDINATE_FIELD_NAMES, + GEOMETRY_SOURCE_DATA_ITEM_ID, +} from '../../../constants/layers.js' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { formatDatetime, @@ -12,6 +16,14 @@ import { ORG_UNIT_QUERY } from '../../../util/orgUnits.js' import Popup from '../Popup.jsx' import styles from './styles/Popup.module.css' +const resolveGeometrySourceLabel = (geometrySource, displayItems) => { + if (COORDINATE_FIELD_NAMES[geometrySource]) { + return COORDINATE_FIELD_NAMES[geometrySource] + } + const item = displayItems?.find((i) => i.id === geometrySource) + return item?.name ?? geometrySource +} + const EVENTS_QUERY = { events: { resource: 'tracker/events', @@ -169,6 +181,19 @@ const EventPopup = ({ {formatCoordinate(coord)} )} + {feature.properties[GEOMETRY_SOURCE_DATA_ITEM_ID] && ( + + {i18n.t('Geometry source')} + + {resolveGeometrySourceLabel( + feature.properties[ + GEOMETRY_SOURCE_DATA_ITEM_ID + ], + displayItems + )} + + + )} {orgUnit && ( {i18n.t('Organisation unit')} diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 0af38432bb..00dd1ba411 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -82,6 +82,8 @@ export const LAYER_EDIT_STYLE_DATA_ITEM_OPTIONS_SET = 'LAYER_EDIT_STYLE_DATA_ITEM_OPTIONS_SET' export const LAYER_EDIT_STYLE_DATA_ITEM_BOOLEAN_SET = 'LAYER_EDIT_STYLE_DATA_ITEM_BOOLEAN_SET' +export const LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET = + 'LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET' export const LAYER_EDIT_THEMATIC_MAP_TYPE_SET = 'LAYER_EDIT_THEMATIC_MAP_TYPE_SET' export const LAYER_EDIT_CLASSIFICATION_SET = 'LAYER_EDIT_CLASSIFICATION_SET' diff --git a/src/constants/layers.js b/src/constants/layers.js index 87aff6c22a..f538c0c4bf 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -236,3 +236,5 @@ export const MIN_RADIUS = 1 export const MAX_RADIUS = 100 export const NONE = 'none' + +export const GEOMETRY_SOURCE_DATA_ITEM_ID = 'geometrySource' diff --git a/src/constants/settings.js b/src/constants/settings.js index f3243b8fd5..22925266fa 100644 --- a/src/constants/settings.js +++ b/src/constants/settings.js @@ -21,6 +21,7 @@ export const SYSTEM_SETTINGS = [ 'keyHideMonthlyPeriods', 'keyHideBiMonthlyPeriods', 'keyDefaultBaseMap', + 'orgUnitCentroidsInEventsAnalytics', ...Object.keys(MAP_SERVICE_KEY_TESTS), ] diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index b55bd48024..3f0fa54a39 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -262,6 +262,18 @@ const layerEdit = (state = null, action) => { return newState + case types.LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET: + return { + ...state, + styleDataItem: { + ...state.styleDataItem, + values: { + ...state.styleDataItem?.values, + [action.value]: action.color, + }, + }, + } + case types.LAYER_EDIT_THEMATIC_MAP_TYPE_SET: newState = { ...state, diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index a6da89dec0..44dcdaf95a 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -5,6 +5,8 @@ import { CLASSIFICATION_PREDEFINED, CLASSIFICATION_LOGARITHMIC, CLASSIFICATION_STANDARD_DEVIATION, + GEOMETRY_SOURCE_DATA_ITEM_ID, + COORDINATE_FIELD_NAMES, } from '../constants/layers.js' import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' import { cssColor } from '../util/colors.js' @@ -64,7 +66,9 @@ const addFeature = (acc, feature, { item, value }) => { // This function is modifiyng the config object before it's added to the redux store export const styleByDataItem = async (config, engine) => { const { styleDataItem } = config - if (styleDataItem.optionSet) { + if (styleDataItem.id === GEOMETRY_SOURCE_DATA_ITEM_ID) { + await styleByGeometrySource(config) + } else if (styleDataItem.optionSet) { await styleByOptionSet(config, engine) } else if (numberValueTypes.includes(styleDataItem.valueType)) { await styleByNumeric(config, engine) @@ -77,6 +81,47 @@ export const styleByDataItem = async (config, engine) => { return config } +const styleByGeometrySource = async (config) => { + const { styleDataItem, data, legend, eventPointRadius, noDataLegend } = + config + const { values } = styleDataItem + + // Build legend items from stored color-per-source values + legend.unit = i18n.t('Geometry source') + legend.items = Object.entries(values || {}).map(([sourceId, color]) => ({ + name: COORDINATE_FIELD_NAMES[sourceId] ?? sourceId, + color, + sourceId, + })) + + const { noDataLegendItem } = addSpecialLegendItems(legend, { noDataLegend }) + stampLegendItems(legend.items, eventPointRadius) + + const itemBySource = Object.fromEntries( + legend.items.filter((i) => i.sourceId).map((i) => [i.sourceId, i]) + ) + + config.data = data.reduce((acc, feature) => { + const geometrySource = feature.properties[GEOMETRY_SOURCE_DATA_ITEM_ID] + const item = geometrySource ? itemBySource[geometrySource] : null + const isNoData = !geometrySource || !item + + if (isNoData && !noDataLegendItem) { + return acc + } + + addFeature(acc, feature, { + item: isNoData ? noDataLegendItem : item, + value: isNoData + ? i18n.t('Not set') + : COORDINATE_FIELD_NAMES[geometrySource] ?? geometrySource, + }) + return acc + }, []) + + return config +} + const styleByDefault = async (config, engine) => { const { styleDataItem, From 3ad451f6c8f7af32c361c73f388f13d744359011 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 10 Jul 2026 11:18:10 +0200 Subject: [PATCH 14/26] chore: sonarqube issue --- src/components/dataItem/CoordinateField.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dataItem/CoordinateField.jsx b/src/components/dataItem/CoordinateField.jsx index ba4628cb0b..c3baadf168 100644 --- a/src/components/dataItem/CoordinateField.jsx +++ b/src/components/dataItem/CoordinateField.jsx @@ -132,7 +132,7 @@ const CoordinateField = ({ if ( trackedEntityType && eventDataItems && - !fields.find((f) => f.id === value) && + !fields.some((f) => f.id === value) && value !== defaultValue && fields.length > 0 ) { @@ -155,7 +155,7 @@ const CoordinateField = ({ : i18n.t('Coordinate field') } items={fields} - value={fields.find((f) => f.id === value) ? value : null} + value={fields.some((f) => f.id === value) ? value : null} loading={ !!program && value !== EVENT_COORDINATE_DEFAULT && itemsLoading } From dc426449f68af4f0e406fe16833005825d6a1da2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 15 Sep 2026 18:59:00 +0200 Subject: [PATCH 15/26] feat: add fallback coordinate field for event layers [DHIS2-8165] --- cypress/elements/event_layer.js | 99 ++++++++++++-- cypress/support/cypressFiles.json | 4 + i18n/en.pot | 14 +- src/actions/layerEdit.js | 3 +- src/components/dataItem/CoordinateField.jsx | 44 +++--- src/components/dataItem/DataItemStyle.jsx | 4 +- src/components/dataItem/StyleByDataItem.jsx | 16 ++- .../__tests__/CoordinateField.spec.jsx | 127 ++++++++++++++++++ src/components/datatable/DataTable.jsx | 4 + .../datatable/__tests__/useTableData.spec.jsx | 54 ++++++++ src/components/datatable/useTableData.js | 6 +- src/components/edit/event/EventDialog.jsx | 11 +- .../edit/event/LabelFieldSelect.jsx | 18 ++- src/components/legend/Legend.jsx | 10 +- .../legend/__tests__/Legend.spec.jsx | 33 +++++ src/components/map/layers/EventLayer.jsx | 18 +-- src/components/map/layers/EventPopup.jsx | 8 +- src/components/plugin/LayerLoader.jsx | 2 +- src/constants/layers.js | 3 +- src/hooks/useLayersLoader.js | 2 +- src/loaders/__tests__/eventLoader.spec.js | 44 +++++- src/loaders/eventLoader.js | 58 +++++++- src/reducers/__tests__/layerEdit.spec.js | 29 ++++ src/reducers/layerEdit.js | 2 + src/util/__tests__/event.spec.js | 25 ++++ src/util/__tests__/geojson.spec.js | 10 ++ src/util/__tests__/versionToggle.spec.js | 19 +++ src/util/coordinatesName.js | 30 +++-- src/util/event.js | 2 + src/util/favorites.js | 1 + src/util/geojson.js | 2 + src/util/styleByDataItem.js | 7 +- src/util/versionToggle.js | 6 + 33 files changed, 637 insertions(+), 78 deletions(-) create mode 100644 src/components/dataItem/__tests__/CoordinateField.spec.jsx create mode 100644 src/components/legend/__tests__/Legend.spec.jsx create mode 100644 src/util/__tests__/versionToggle.spec.js create mode 100644 src/util/versionToggle.js diff --git a/cypress/elements/event_layer.js b/cypress/elements/event_layer.js index d61604281b..e0b2c7c25d 100644 --- a/cypress/elements/event_layer.js +++ b/cypress/elements/event_layer.js @@ -1,6 +1,63 @@ import { EXTENDED_TIMEOUT } from '../support/util.js' import { Layer } from './layer.js' +// Waits for the popper's text to stabilize before clicking. +const clickStablePopperItem = (text) => { + let lastSignature = null + let stableCount = 0 + + cy.get( + '[data-test="dhis2-uicore-popper"]:visible', + EXTENDED_TIMEOUT + ).should(($popper) => { + const signature = $popper.text() + if (signature === lastSignature) { + stableCount++ + } else { + stableCount = 0 + lastSignature = signature + } + expect(stableCount, 'popper content settled').to.be.at.least(1) + }) + + cy.get('[data-test="dhis2-uicore-popper"]:visible') + .containsExact(text) + .click() +} + +const MAX_SELECT_ATTEMPTS = 3 + +// Selects targetText, then retries the whole open+click if it didn't take - +// clickStablePopperItem() narrows the detached-click race but doesn't close it. +const selectPopperOption = ( + contentDataTest, + targetText, + attemptsLeft = MAX_SELECT_ATTEMPTS +) => { + cy.getByDataTest(contentDataTest, EXTENDED_TIMEOUT).then(($element) => { + if ($element.text().trim() === targetText) { + return + } + + cy.getByDataTest(contentDataTest).click() + clickStablePopperItem(targetText) + + cy.getByDataTest(contentDataTest).then(($after) => { + if ($after.text().trim() !== targetText) { + expect( + attemptsLeft, + `select "${targetText}" eventually took effect` + ).to.be.greaterThan(1) + selectPopperOption( + contentDataTest, + targetText, + attemptsLeft - 1 + ) + } + }) + }) +} + export class EventLayer extends Layer { selectProgram(program) { cy.get('[data-test="programselect"]', EXTENDED_TIMEOUT).click() @@ -21,19 +78,17 @@ export class EventLayer extends Layer { cy.getByDataTest('coordinatefield-content', EXTENDED_TIMEOUT).should( ($el) => expect($el.text().trim().length).to.be.greaterThan(0) ) + selectPopperOption('coordinatefield-content', coordinate) - cy.getByDataTest('coordinatefield-content').then(($element) => { - // Check if the coordinate is already selected by looking at the text content - if ($element.text().trim() !== coordinate) { - cy.log('Select the coordinate') - cy.getByDataTest('coordinatefield-content').click() - cy.getByDataTest('dhis2-uicore-popper') - .containsExact(coordinate) - .click() - } else { - cy.log('Coordinate already selected, no action needed') - } - }) + return this + } + + selectFallbackCoordinate(coordinate) { + cy.getByDataTest( + 'fallbackcoordinatefield-content', + EXTENDED_TIMEOUT + ).should(($el) => expect($el.text().trim().length).to.be.greaterThan(0)) + selectPopperOption('fallbackcoordinatefield-content', coordinate) return this } @@ -76,4 +131,24 @@ export class EventLayer extends Layer { return this } + + selectCountEventsWithoutCoordinates() { + cy.getByDataTest('eventdialog-styletab') + .contains('Count events without coordinates') + .click() + + return this + } + + selectLabelField(name) { + cy.getByDataTest('eventdialog-styletab').then(($tab) => { + if (!$tab.find('[data-test="label-field-select-content"]').length) { + cy.wrap($tab).contains('Labels').click() + } + }) + + selectPopperOption('label-field-select-content', name) + + return this + } } diff --git a/cypress/support/cypressFiles.json b/cypress/support/cypressFiles.json index c349aedc3d..0f04446e47 100644 --- a/cypress/support/cypressFiles.json +++ b/cypress/support/cypressFiles.json @@ -107,5 +107,9 @@ "cypress/integration/layers/trackedentitylayer.cy.js": { "include": true, "duration": 38 + }, + "cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js": { + "include": false, + "note": "yarn cypress run --spec cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js" } } diff --git a/i18n/en.pot b/i18n/en.pot index 5c6a8cfb0b..897c362358 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-01T12:42:29.454Z\n" -"PO-Revision-Date: 2026-09-01T12:42:29.454Z\n" +"POT-Creation-Date: 2026-09-15T16:43:35.708Z\n" +"PO-Revision-Date: 2026-09-15T16:43:35.708Z\n" msgid "2020" msgstr "2020" @@ -101,8 +101,8 @@ msgstr "Enrollment location" msgid "Tracked entity location" msgstr "Tracked entity location" -msgid "Enrollment > event > tracked entity > org unit coordinate" -msgstr "Enrollment > event > tracked entity > org unit coordinate" +msgid "Event > enrollment > tracked entity > org unit coordinate" +msgstr "Event > enrollment > tracked entity > org unit coordinate" msgid "Event > org unit coordinate" msgstr "Event > org unit coordinate" @@ -713,6 +713,9 @@ msgstr "Duplicate layer" msgid "Remove layer" msgstr "Remove layer" +msgid "fallback" +msgstr "fallback" + msgid "Filters" msgstr "Filters" @@ -1837,6 +1840,9 @@ msgstr "The event filter is not supported" msgid "An unknown error occurred while reading layer data" msgstr "An unknown error occurred while reading layer data" +msgid "The fallback coordinate field is not supported by this server version" +msgstr "The fallback coordinate field is not supported by this server version" + msgid "Displaying first {{pageSize}} events out of {{total}}" msgstr "Displaying first {{pageSize}} events out of {{total}}" diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index cbede437a5..9278c82a01 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -122,9 +122,10 @@ export const setEventCoordinateField = (fieldId, fieldType) => ({ }) // Set fallback coordinate field -export const setFallbackCoordinateField = (fieldId) => ({ +export const setFallbackCoordinateField = (fieldId, fieldType) => ({ type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, fieldId, + fieldType, }) // Set if event clustering should be used (event) diff --git a/src/components/dataItem/CoordinateField.jsx b/src/components/dataItem/CoordinateField.jsx index c3baadf168..2dc16143d8 100644 --- a/src/components/dataItem/CoordinateField.jsx +++ b/src/components/dataItem/CoordinateField.jsx @@ -10,6 +10,11 @@ import { EVENT_COORDINATE_CASCADING, NONE, } from '../../constants/layers.js' +import { + coordinateValueTypes, + ouValueTypes, +} from '../../constants/valueTypes.js' +import { serverSupportsGeometrySource } from '../../util/versionToggle.js' import { SelectField } from '../core/index.js' import { useEventDataItems } from './EventDataItemsProvider.jsx' @@ -24,19 +29,26 @@ const CoordinateField = ({ dataTest = 'coordinatefield', }) => { const { serverVersion } = useConfig() - - // VERSION-TOGGLE - // https://dhis2.atlassian.net/browse/DHIS2-19010 and: - // - [2.40.8] https://github.com/dhis2/dhis2-core/commit/f2286a5aa70b2957bd24925776e9394cd67d44c1 - // - [2.41.4] https://github.com/dhis2/dhis2-core/commit/19f29f27385cfae1c7fac234439f49987ec2abe4 - // - [2.42.0] https://github.com/dhis2/dhis2-core/commit/e5b29f4f1dbee791be9e6befb8a304151a1661c9 - const includeTypes = ['COORDINATE'] - if ( + const isFallback = !!eventCoordinateField + + const includeTypes = [...coordinateValueTypes] + if (isFallback) { + // VERSION-TOGGLE: fallbackCoordinateField pointed at a custom + // ORGANISATION_UNIT field crashes pre-2.44 - see util/versionToggle.js + if (serverSupportsGeometrySource(serverVersion)) { + includeTypes.push(...ouValueTypes) + } + } else if ( + // VERSION-TOGGLE + // https://dhis2.atlassian.net/browse/DHIS2-19010 and: + // - [2.40.8] https://github.com/dhis2/dhis2-core/commit/f2286a5aa70b2957bd24925776e9394cd67d44c1 + // - [2.41.4] https://github.com/dhis2/dhis2-core/commit/19f29f27385cfae1c7fac234439f49987ec2abe4 + // - [2.42.0] https://github.com/dhis2/dhis2-core/commit/e5b29f4f1dbee791be9e6befb8a304151a1661c9 (serverVersion.minor === 40 && serverVersion.patch >= 8) || (serverVersion.minor === 41 && serverVersion.patch >= 4) || serverVersion.minor >= 42 ) { - includeTypes.push('ORGANISATION_UNIT') + includeTypes.push(...ouValueTypes) } const { @@ -51,7 +63,6 @@ const CoordinateField = ({ ) const fields = useMemo(() => { - const isFallback = !!eventCoordinateField const fields = [] if (isFallback) { @@ -96,17 +107,17 @@ const CoordinateField = ({ return isFallback ? fields.filter((f) => f.id !== eventCoordinateField) : fields - }, [trackedEntityType, eventDataItems, eventCoordinateField]) + }, [trackedEntityType, eventDataItems, eventCoordinateField, isFallback]) let helpText = null if (program) { if (value === EVENT_COORDINATE_CASCADING) { - helpText = trackedEntityType + helpText = trackedEntityType?.id ? i18n.t( - 'Enrollment > event > tracked entity > org unit coordinate' + 'Event > enrollment > tracked entity > org unit coordinate' ) : i18n.t('Event > org unit coordinate') - } else if (!programStage && trackedEntityType) { + } else if (!programStage && trackedEntityType?.id) { helpText = i18n.t( 'Select a program stage to see additional coordinate options' ) @@ -173,7 +184,10 @@ CoordinateField.propTypes = { onChange: PropTypes.func.isRequired, className: PropTypes.string, dataTest: PropTypes.string, - eventCoordinateField: PropTypes.string, + eventCoordinateField: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + ]), program: PropTypes.object, programStage: PropTypes.object, type: PropTypes.string, diff --git a/src/components/dataItem/DataItemStyle.jsx b/src/components/dataItem/DataItemStyle.jsx index e32b39ba0e..4abb5c93d0 100644 --- a/src/components/dataItem/DataItemStyle.jsx +++ b/src/components/dataItem/DataItemStyle.jsx @@ -6,7 +6,7 @@ import { setNoDataLegend, setUnclassifiedLegend, } from '../../actions/layerEdit.js' -import { GEOMETRY_SOURCE_DATA_ITEM_ID } from '../../constants/layers.js' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' import { numberValueTypes, booleanValueTypes, @@ -30,7 +30,7 @@ const DataItemStyle = ({ dataItem, style }) => { return null } - if (dataItem.id === GEOMETRY_SOURCE_DATA_ITEM_ID) { + if (dataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE) { return (
diff --git a/src/components/dataItem/StyleByDataItem.jsx b/src/components/dataItem/StyleByDataItem.jsx index 9f2bc6f88d..82975e46e9 100644 --- a/src/components/dataItem/StyleByDataItem.jsx +++ b/src/components/dataItem/StyleByDataItem.jsx @@ -1,10 +1,12 @@ +import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import { Help } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { setStyleDataItem } from '../../actions/layerEdit.js' -import { GEOMETRY_SOURCE_DATA_ITEM_ID } from '../../constants/layers.js' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' +import { serverSupportsGeometrySource } from '../../util/versionToggle.js' import { SelectField } from '../core/index.js' import DataItemStyle from './DataItemStyle.jsx' import { useEventDataItems } from './EventDataItemsProvider.jsx' @@ -17,7 +19,7 @@ const excludeTypes = [ ] const GEOMETRY_SOURCE_ITEM = { - id: GEOMETRY_SOURCE_DATA_ITEM_ID, + id: EVENT_COORDINATE_GEOMETRY_SOURCE, name: i18n.t('Geometry source'), } @@ -25,6 +27,7 @@ const GEOMETRY_SOURCE_ITEM = { // Displays a select field with data items that support styling. // Styling options are shown when a data item is selected. const StyleByDataItem = ({ error }) => { + const { serverVersion } = useConfig() const styleDataItem = useSelector((state) => state.layerEdit.styleDataItem) const dispatch = useDispatch() const { eventDataItems } = useEventDataItems({ excludeTypes }) @@ -38,7 +41,14 @@ const StyleByDataItem = ({ error }) => { return null } - const dataItems = [ITEM_NONE, GEOMETRY_SOURCE_ITEM, ...eventDataItems] + // VERSION-TOGGLE: geometrySource only exists on 2.44+ - see util/versionToggle.js. + const dataItems = [ + ITEM_NONE, + ...(serverSupportsGeometrySource(serverVersion) + ? [GEOMETRY_SOURCE_ITEM] + : []), + ...eventDataItems, + ] const internalError = styleDataItem && !dataItems.find((item) => item.id === styleDataItem.id) diff --git a/src/components/dataItem/__tests__/CoordinateField.spec.jsx b/src/components/dataItem/__tests__/CoordinateField.spec.jsx new file mode 100644 index 0000000000..637cb7023e --- /dev/null +++ b/src/components/dataItem/__tests__/CoordinateField.spec.jsx @@ -0,0 +1,127 @@ +import { render, screen, fireEvent, act } from '@testing-library/react' +import React from 'react' +import CoordinateField from '../CoordinateField.jsx' + +const mockUseConfig = jest.fn() +jest.mock('@dhis2/app-runtime', () => ({ + useConfig: (...args) => mockUseConfig(...args), +})) + +const mockUseEventDataItems = jest.fn() +jest.mock('../EventDataItemsProvider.jsx', () => ({ + useEventDataItems: (...args) => mockUseEventDataItems(...args), +})) + +const ouTypeItem = { + id: 'ouFieldId1', + name: 'OU Field', + valueType: 'ORGANISATION_UNIT', +} + +// OU-type fallback (2.44+) is a separate, later gate than OU-type main fields (2.42+). +describe('CoordinateField', () => { + beforeEach(() => { + mockUseEventDataItems.mockReturnValue({ + eventDataItems: [ouTypeItem], + trackedEntityType: null, + loading: false, + }) + }) + + it('includes ORGANISATION_UNIT in the main field type filter regardless of version', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 43 } }) + + render( + + ) + + expect(mockUseEventDataItems).toHaveBeenCalledWith({ + includeTypes: ['COORDINATE', 'ORGANISATION_UNIT'], + }) + }) + + it('excludes ORGANISATION_UNIT from the fallback field type filter pre-2.44', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 43 } }) + + render( + + ) + + expect(mockUseEventDataItems).toHaveBeenCalledWith({ + includeTypes: ['COORDINATE'], + }) + }) + + it('includes ORGANISATION_UNIT in the fallback field type filter on 2.44+', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + + render( + + ) + + expect(mockUseEventDataItems).toHaveBeenCalledWith({ + includeTypes: ['COORDINATE', 'ORGANISATION_UNIT'], + }) + }) + + it('renders the OU-type field as a selectable option when included', async () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + // Custom items only appear once a tracked entity type exists. + mockUseEventDataItems.mockReturnValue({ + eventDataItems: [ouTypeItem], + trackedEntityType: { id: 'tet1' }, + loading: false, + }) + + render( + + ) + + await act(async () => { + await fireEvent.click( + screen.getByTestId('dhis2-uicore-select-input') + ) + }) + + const labels = screen + .getAllByTestId('dhis2-uicore-singleselectoption') + .map((el) => el.textContent) + expect(labels).toContain('OU Field') + }) + + it('shows the org-unit-only cascade help text for a program without registration', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + // trackedEntityType resolves to {} (not null) for WITHOUT_REGISTRATION programs. + mockUseEventDataItems.mockReturnValue({ + eventDataItems: [ouTypeItem], + trackedEntityType: {}, + loading: false, + }) + + render( + + ) + + expect(screen.getByText('Event > org unit coordinate')).toBeTruthy() + expect( + screen.queryByText( + 'Event > enrollment > tracked entity > org unit coordinate' + ) + ).toBeNull() + }) +}) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3d92479c39..43539249fe 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -215,6 +215,10 @@ const Table = ({ availableWidth }) => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling if (columnWidths.length === 0 && headerRowRef.current) { requestAnimationFrame(() => { + if (!headerRowRef.current) { + return + } + const measuredColumnWidths = [] for (const cell of headerRowRef.current.cells) { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index aa8d588656..a88837b90b 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -289,6 +289,60 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('includes a geometrySource column when present in the response headers', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'geometrySource', + column: 'Geometry source', + valueType: 'TEXT', + }, + ], + data: [ + { + properties: { + id: 'a9712323629', + type: 'Point', + ouname: 'Lumley Hospital', + eventdate: '2023-05-15 00:00:00.0', + geometrySource: 'ougeometry', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Geometry source', + dataKey: 'geometrySource', + type: 'string', + }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 'ougeometry', + dataKey: 'geometrySource', + }) + ) + }) + test('treats NUMBER header with optionSet as string type', () => { const store = { aggregations: {} } const layer = { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index ccbc96ab8f..8edd8f97a9 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -8,6 +8,7 @@ import { EARTH_ENGINE_LAYER, FACILITY_LAYER, GEOJSON_URL_LAYER, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../../constants/layers.js' import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' @@ -125,7 +126,10 @@ const getEventHeaders = ({ } const customFields = layerHeaders - .filter(({ name }) => isValidUid(name)) + .filter( + ({ name }) => + isValidUid(name) || name === EVENT_COORDINATE_GEOMETRY_SOURCE + ) .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ name, dataKey, diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 171ca235f9..23418ac0a2 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -81,6 +81,7 @@ const EventDialog = ({ eventPointRadius, eventStatus, fallbackCoordinateField = 'none', + fallbackCoordinateFieldType = 'none', filters, legendIsolated, legendSet, @@ -323,13 +324,16 @@ const EventDialog = ({ program={program} programStage={programStage} value={fallbackCoordinateField} - type={fallbackCoordinateField} + type={fallbackCoordinateFieldType} eventCoordinateField={ eventCoordinateField || true } - onChange={(fieldId) => + onChange={(fieldId, fieldType) => dispatch( - setFallbackCoordinateField(fieldId) + setFallbackCoordinateField( + fieldId, + fieldType + ) ) } className={styles.select} @@ -570,6 +574,7 @@ EventDialog.propTypes = { eventPointRadius: PropTypes.number, eventStatus: PropTypes.string, fallbackCoordinateField: PropTypes.string, + fallbackCoordinateFieldType: PropTypes.string, filters: PropTypes.array, legendIsolated: PropTypes.object, legendSet: PropTypes.object, diff --git a/src/components/edit/event/LabelFieldSelect.jsx b/src/components/edit/event/LabelFieldSelect.jsx index cfa1a323f0..43752e4e84 100644 --- a/src/components/edit/event/LabelFieldSelect.jsx +++ b/src/components/edit/event/LabelFieldSelect.jsx @@ -1,13 +1,22 @@ +import { useConfig } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { setLabelDataItem } from '../../../actions/layerEdit.js' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../../constants/layers.js' +import { serverSupportsGeometrySource } from '../../../util/versionToggle.js' import { useEventDataItems } from '../../dataItem/EventDataItemsProvider.jsx' import Labels from '../shared/Labels.jsx' const ITEM_NONE = { id: 'none', name: i18n.t('None') } +const GEOMETRY_SOURCE_ITEM = { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + name: i18n.t('Geometry source'), +} + const LabelFieldSelect = () => { + const { serverVersion } = useConfig() const labelDataItemId = useSelector( (state) => state.layerEdit.labelDataItem?.id ) @@ -18,7 +27,14 @@ const LabelFieldSelect = () => { return null } - const items = [ITEM_NONE, ...eventDataItems] + // VERSION-TOGGLE: geometrySource only exists on 2.44+ - see util/versionToggle.js. + const items = [ + ITEM_NONE, + ...(serverSupportsGeometrySource(serverVersion) + ? [GEOMETRY_SOURCE_ITEM] + : []), + ...eventDataItems, + ] return (
{i18n.t('Coordinate field')}
{coordinateFields.map((coordinateField, index) => ( -
{coordinateField}
+
+ {coordinateField} + {fallbackCoordinateField && + ` (${i18n.t( + 'fallback' + )}: ${fallbackCoordinateField})`} +
))}
)} @@ -402,6 +409,7 @@ Legend.propTypes = { eventsOutsideOrgUnitsCount: PropTypes.number, eventsWithoutCoordinatesCount: PropTypes.number, explanation: PropTypes.array, + fallbackCoordinateField: PropTypes.string, filters: PropTypes.array, groups: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), isPlugin: PropTypes.bool, diff --git a/src/components/legend/__tests__/Legend.spec.jsx b/src/components/legend/__tests__/Legend.spec.jsx new file mode 100644 index 0000000000..676de132bb --- /dev/null +++ b/src/components/legend/__tests__/Legend.spec.jsx @@ -0,0 +1,33 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import Legend from '../Legend.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: jest.fn(() => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, + })), +})) + +describe('Legend coordinate field / fallback display', () => { + it('shows only the coordinate field when there is no fallback', () => { + render() + + expect(screen.getByText('Event location')).toBeTruthy() + expect(screen.queryByText(/fallback/)).toBeNull() + }) + + it('shows the fallback alongside the coordinate field when set', () => { + render( + + ) + + expect( + screen.getByText( + 'Event location (fallback: Organisation unit location)' + ) + ).toBeTruthy() + }) +}) diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index b132e09e74..5791716033 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -8,7 +8,7 @@ import { LABEL_TEMPLATE_TOOLTIP_ONLY, } from '../../../constants/layers.js' import { getContrastColor } from '../../../util/colors.js' -import { loadEventCoordinateFieldName } from '../../../util/coordinatesName.js' +import { loadEventCoordinateField } from '../../../util/coordinatesName.js' import { getAnalyticsRequest, EVENT_PROGRAM_STAGE_DATA_ELEMENTS_QUERY, @@ -404,13 +404,15 @@ class EventLayer extends Layer { ] } - const eventCoordinateFieldName = await loadEventCoordinateFieldName({ - program, - programStage, - eventCoordinateField, - engine, - displayNameProp, - }) + const eventCoordinateFieldName = ( + await loadEventCoordinateField({ + program, + programStage, + fieldId: eventCoordinateField, + engine, + displayNameProp, + }) + )?.name this.setState({ displayItems, eventCoordinateFieldName }) } diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index d691531cf4..88000db4a9 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { COORDINATE_FIELD_NAMES, - GEOMETRY_SOURCE_DATA_ITEM_ID, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../../../constants/layers.js' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { @@ -181,13 +181,15 @@ const EventPopup = ({ {formatCoordinate(coord)} )} - {feature.properties[GEOMETRY_SOURCE_DATA_ITEM_ID] && ( + {feature.properties[ + EVENT_COORDINATE_GEOMETRY_SOURCE + ] && ( {i18n.t('Geometry source')} {resolveGeometrySourceLabel( feature.properties[ - GEOMETRY_SOURCE_DATA_ITEM_ID + EVENT_COORDINATE_GEOMETRY_SOURCE ], displayItems )} diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index bd20f0ddcb..bfa17bb43d 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -58,7 +58,7 @@ const LayerLoader = ({ config, onLoad }) => { baseUrl, analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader - serverVersion, // Tracked entity loader + serverVersion, // Tracked entity and Event loader }).then((result) => { onLoad(result) }) diff --git a/src/constants/layers.js b/src/constants/layers.js index a57c8473b0..a4d03e2524 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -108,6 +108,7 @@ export const EVENT_COORDINATE_ORG_UNIT = 'ougeometry' export const EVENT_COORDINATE_ENROLLMENT = 'pigeometry' export const EVENT_COORDINATE_TRACKED_ENTITY = 'teigeometry' export const EVENT_COORDINATE_CASCADING = 'cascading' +export const EVENT_COORDINATE_GEOMETRY_SOURCE = 'geometrySource' export const COORDINATE_FIELD_NAMES = { [EVENT_COORDINATE_DEFAULT]: i18n.t('Event location'), [EVENT_COORDINATE_ORG_UNIT]: i18n.t('Organisation unit location'), @@ -237,5 +238,3 @@ export const MIN_RADIUS = 1 export const MAX_RADIUS = 100 export const NONE = 'none' - -export const GEOMETRY_SOURCE_DATA_ITEM_ID = 'geometrySource' diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 194765c4cf..c3b5f1f7d5 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -63,7 +63,7 @@ export const useLayersLoader = () => { baseUrl, analyticsEngine, // Thematic and Event loader periodTypeData, // Thematic and Event loader - serverVersion, // Tracked entity loader + serverVersion, // Tracked entity and Event loader loadExtended: !!dataTable, // Event loader }) if (result.alerts) { diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index ab9af8ba2d..fdd94299f1 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -9,7 +9,10 @@ import { GEOFEATURES_QUERY, ORG_UNITS_PATHS_QUERY, } from '../../util/requests.js' -import { excludeEventsOutsideOrgUnits } from '../eventLoader.js' +import { + excludeEventsOutsideOrgUnits, + isUnsupportedFallbackField, +} from '../eventLoader.js' // [0,0]-[10,10] const SQUARE_A = [ @@ -729,3 +732,42 @@ describe('excludeEventsOutsideOrgUnits', () => { expect(config.legend.orgUnitsWithoutBoundaryCount).toBeUndefined() }) }) + +describe('isUnsupportedFallbackField', () => { + test('an ORGANISATION_UNIT-type field is unsupported pre-2.44', () => { + expect( + isUnsupportedFallbackField( + { valueType: 'ORGANISATION_UNIT' }, + { + minor: 43, + } + ) + ).toBe(true) + }) + + test('an ORGANISATION_UNIT-type field is supported on 2.44+', () => { + expect( + isUnsupportedFallbackField( + { valueType: 'ORGANISATION_UNIT' }, + { + minor: 44, + } + ) + ).toBe(false) + }) + + test('a COORDINATE-type field is always supported', () => { + expect( + isUnsupportedFallbackField( + { valueType: 'COORDINATE' }, + { + minor: 43, + } + ) + ).toBe(false) + }) + + test('an unresolved field (undefined) is treated as supported', () => { + expect(isUnsupportedFallbackField(undefined, { minor: 43 })).toBe(false) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index da3b847c16..fee411b30c 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -12,6 +12,7 @@ import { EVENT_SERVER_CLUSTER_COUNT, EVENT_COLOR, EVENT_RADIUS, + EVENT_COORDINATE_CASCADING, } from '../constants/layers.js' import { numberValueTypes } from '../constants/valueTypes.js' import { @@ -23,7 +24,7 @@ import { } from '../util/analytics.js' import { cssColor, getContrastColor } from '../util/colors.js' import { parseJsonConfig } from '../util/config.js' -import { loadEventCoordinateFieldName } from '../util/coordinatesName.js' +import { loadEventCoordinateField } from '../util/coordinatesName.js' import { getAnalyticsRequest, loadData } from '../util/event.js' import { getBounds, @@ -41,6 +42,7 @@ import { OPTION_SET_QUERY } from '../util/requests.js' import { styleByDataItem } from '../util/styleByDataItem.js' import { formatStartEndDate, getDateArray } from '../util/time.js' import { isValidUid } from '../util/uid.js' +import { serverSupportsGeometrySource } from '../util/versionToggle.js' // OU dimension value is always an ID; property key depends on outputIdScheme const getEventOuId = (feature) => @@ -86,6 +88,19 @@ const unknownErrorAlert = { code: CUSTOM_ALERT, message: i18n.t('An unknown error occurred while reading layer data'), } +const fallbackCoordinateFieldUnsupportedAlert = { + warning: true, + code: CUSTOM_ALERT, + message: i18n.t( + 'The fallback coordinate field is not supported by this server version' + ), +} + +// VERSION-TOGGLE: ORGANISATION_UNIT fallback fields crash pre-2.44 - see +// util/versionToggle.js. Resolved via query since valueType isn't stored. +export const isUnsupportedFallbackField = (fallbackField, serverVersion) => + fallbackField?.valueType === 'ORGANISATION_UNIT' && + !serverSupportsGeometrySource(serverVersion) // Returns a promise const eventLoader = async ({ @@ -97,6 +112,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + serverVersion, }) => { const config = { ...layerConfig, @@ -117,6 +133,7 @@ const eventLoader = async ({ analyticsEngine, periodTypeData, loadExtended, + serverVersion, }) } catch (e) { if ( @@ -149,6 +166,7 @@ const loadEventLayer = async ({ analyticsEngine, periodTypeData, loadExtended, + serverVersion, }) => { // Config normalization // ----- @@ -237,12 +255,34 @@ const loadEventLayer = async ({ config.isExtended = loadExtended + const alerts = [] + + // Fallback coordinate field + // ----- + + let fallbackField = + config.fallbackCoordinateField && + config.fallbackCoordinateField !== EVENT_COORDINATE_CASCADING + ? await loadEventCoordinateField({ + program, + programStage, + fieldId: config.fallbackCoordinateField, + engine, + displayNameProp, + }) + : null + + if (isUnsupportedFallbackField(fallbackField, serverVersion)) { + delete config.fallbackCoordinateField + fallbackField = null + alerts.push(fallbackCoordinateFieldUnsupportedAlert) + } + const analyticsRequest = await getAnalyticsRequest(config, { analyticsEngine, nameProperty: displayNameProp, engine, }) - const alerts = [] // Legend skeleton // ----- @@ -403,15 +443,21 @@ const loadEventLayer = async ({ // Coordinate field // ----- - const eventCoordinateFieldName = await loadEventCoordinateFieldName({ + const coordinateField = await loadEventCoordinateField({ program, programStage, - eventCoordinateField, + fieldId: eventCoordinateField, engine, displayNameProp, }) - if (eventCoordinateFieldName) { - config.legend.coordinateFields = [eventCoordinateFieldName] + if (coordinateField) { + config.legend.coordinateFields = [coordinateField.name] + } + + if (config.fallbackCoordinateField === EVENT_COORDINATE_CASCADING) { + config.legend.fallbackCoordinateField = i18n.t('Cascading') + } else if (fallbackField) { + config.legend.fallbackCoordinateField = fallbackField.name } // Legend items & explanation diff --git a/src/reducers/__tests__/layerEdit.spec.js b/src/reducers/__tests__/layerEdit.spec.js index fd825639f9..2f6a000341 100644 --- a/src/reducers/__tests__/layerEdit.spec.js +++ b/src/reducers/__tests__/layerEdit.spec.js @@ -1323,4 +1323,33 @@ describe('layerEdit reducer', () => { expect(layerEdit(state, { type: 'UNKNOWN' })).toBe(state) }) + + it('sets fallbackCoordinateField and fallbackCoordinateFieldType together', () => { + const result = layerEdit( + {}, + { + type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + fieldId: 'deId123456', + fieldType: 'ORGANISATION_UNIT', + } + ) + + expect(result.fallbackCoordinateField).toBe('deId123456') + expect(result.fallbackCoordinateFieldType).toBe('ORGANISATION_UNIT') + }) + + it('clears fallbackCoordinateField and fallbackCoordinateFieldType when set to none', () => { + const state = { + fallbackCoordinateField: 'deId123456', + fallbackCoordinateFieldType: 'ORGANISATION_UNIT', + } + + const result = layerEdit(state, { + type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + fieldId: 'none', + }) + + expect(result.fallbackCoordinateField).toBeUndefined() + expect(result.fallbackCoordinateFieldType).toBeUndefined() + }) }) diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 3daf0d295d..d5a05ee037 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -371,8 +371,10 @@ const layerEdit = (state = null, action) => { if (action.fieldId === NONE) { delete newState.fallbackCoordinateField + delete newState.fallbackCoordinateFieldType } else { newState.fallbackCoordinateField = action.fieldId + newState.fallbackCoordinateFieldType = action.fieldType } return newState diff --git a/src/util/__tests__/event.spec.js b/src/util/__tests__/event.spec.js index 415200f71e..c2e0a8878d 100644 --- a/src/util/__tests__/event.spec.js +++ b/src/util/__tests__/event.spec.js @@ -1,3 +1,4 @@ +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' import { getAnalyticsRequest } from '../event.js' const mockRequestInstance = { @@ -138,4 +139,28 @@ describe('getAnalyticsRequest', () => { '2023Q2', ]) }) + + it('never adds geometrySource as a dimension when it is the labelDataItem', async () => { + const layer = { + ...baseLayer, + labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + } + + await getAnalyticsRequest(layer, baseContext) + + expect( + getDimensionCalls(EVENT_COORDINATE_GEOMETRY_SOURCE) + ).toHaveLength(0) + }) + + it('adds a non-geometrySource labelDataItem as a dimension', async () => { + const layer = { + ...baseLayer, + labelDataItem: { id: 'fakeDataElId' }, + } + + await getAnalyticsRequest(layer, baseContext) + + expect(getDimensionCalls('fakeDataElId')).toHaveLength(1) + }) }) diff --git a/src/util/__tests__/geojson.spec.js b/src/util/__tests__/geojson.spec.js index 82247a8d98..725dbb337b 100644 --- a/src/util/__tests__/geojson.spec.js +++ b/src/util/__tests__/geojson.spec.js @@ -1,3 +1,4 @@ +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../constants/layers.js' import { GEO_TYPE_POINT, GEO_TYPE_FEATURE, @@ -303,6 +304,15 @@ describe('geojson utils', () => { name: newItem.name, }) }) + it('Should not add geometrySource as a dimension - the backend returns it automatically', () => { + const geometrySourceItem = { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + name: 'Geometry source', + } + const result = addStyleDataItem(dummyDataItems, geometrySourceItem) + expect(result).not.toBe(dummyDataItems) // New array, not === + expect(result).toEqual(dummyDataItems) + }) }) describe('getBounds', () => { diff --git a/src/util/__tests__/versionToggle.spec.js b/src/util/__tests__/versionToggle.spec.js new file mode 100644 index 0000000000..163a3b6e82 --- /dev/null +++ b/src/util/__tests__/versionToggle.spec.js @@ -0,0 +1,19 @@ +import { serverSupportsGeometrySource } from '../versionToggle.js' + +describe('serverSupportsGeometrySource', () => { + it('returns false below 2.44', () => { + expect(serverSupportsGeometrySource({ minor: 43 })).toBe(false) + }) + + it('returns true at 2.44', () => { + expect(serverSupportsGeometrySource({ minor: 44 })).toBe(true) + }) + + it('returns true above 2.44', () => { + expect(serverSupportsGeometrySource({ minor: 45 })).toBe(true) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsGeometrySource(undefined)).toBe(false) + }) +}) diff --git a/src/util/coordinatesName.js b/src/util/coordinatesName.js index 0fcdef009d..1246d28f62 100644 --- a/src/util/coordinatesName.js +++ b/src/util/coordinatesName.js @@ -4,19 +4,21 @@ import { EVENT_PROGRAM_ATTRIBUTES_QUERY, } from '../util/event.js' -export const loadEventCoordinateFieldName = async ({ +// Resolves a coordinate field id to its name/valueType - only custom DE/TEA +// fields need the query, built-in fields (psigeometry etc.) are static. +export const loadEventCoordinateField = async ({ program, programStage, - eventCoordinateField, + fieldId, engine, displayNameProp, }) => { - if (!eventCoordinateField) { - return + if (!fieldId) { + return undefined } - if (COORDINATE_FIELD_NAMES[eventCoordinateField]) { - return COORDINATE_FIELD_NAMES[eventCoordinateField] + if (COORDINATE_FIELD_NAMES[fieldId]) { + return { name: COORDINATE_FIELD_NAMES[fieldId] } } const { programStage: programStageData } = await engine.query( @@ -28,10 +30,13 @@ export const loadEventCoordinateFieldName = async ({ const { programStageDataElements } = programStageData if (Array.isArray(programStageDataElements)) { const coordElement = programStageDataElements.find( - (d) => d.dataElement.id === eventCoordinateField + (d) => d.dataElement.id === fieldId ) if (coordElement) { - return coordElement.dataElement.name + return { + name: coordElement.dataElement.name, + valueType: coordElement.dataElement.valueType, + } } } @@ -47,10 +52,15 @@ export const loadEventCoordinateFieldName = async ({ const { programTrackedEntityAttributes } = programData if (Array.isArray(programTrackedEntityAttributes)) { const coordAttribute = programTrackedEntityAttributes.find( - (d) => d.trackedEntityAttribute.id === eventCoordinateField + (d) => d.trackedEntityAttribute.id === fieldId ) if (coordAttribute) { - return coordAttribute.trackedEntityAttribute.name + return { + name: coordAttribute.trackedEntityAttribute.name, + valueType: coordAttribute.trackedEntityAttribute.valueType, + } } } + + return undefined } diff --git a/src/util/event.js b/src/util/event.js index 4342b9e1d3..9c0e9cb1e7 100644 --- a/src/util/event.js +++ b/src/util/event.js @@ -2,6 +2,7 @@ import { EVENT_CLIENT_PAGE_SIZE, EVENT_COORDINATE_CASCADING, EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../constants/layers.js' import { getOrgUnitsFromRows, @@ -90,6 +91,7 @@ export const getAnalyticsRequest = async ( // Add label data item dimension if not already in the request if ( labelDataItem?.id && + labelDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE && !dataItems.some((item) => item.dimension === labelDataItem.id) ) { dataItems.push({ dimension: labelDataItem.id }) diff --git a/src/util/favorites.js b/src/util/favorites.js index 520c919873..7d6e8e14c2 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -41,6 +41,7 @@ const validLayerProperties = [ 'displayName', 'endDate', 'eventCoordinateField', + 'fallbackCoordinateField', 'eventClustering', 'eventPointColor', 'eventPointRadius', diff --git a/src/util/geojson.js b/src/util/geojson.js index ce6b0d372a..6b1983d378 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,6 +1,7 @@ import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' import turfCentroid from '@turf/centroid' import findIndex from 'lodash/findIndex' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../constants/layers.js' export const EVENT_ID_FIELD = 'psi' @@ -141,6 +142,7 @@ export const createEventFeatures = (response, config = {}) => { // Include column for data element used for styling (if not already used in filter) export const addStyleDataItem = (dataItems, styleDataItem) => styleDataItem && + styleDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE && !dataItems.find((item) => item.dimension === styleDataItem.id) ? [ ...dataItems, diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index a557e28ff6..65d07c4703 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -5,7 +5,7 @@ import { CLASSIFICATION_PREDEFINED, CLASSIFICATION_LOGARITHMIC, CLASSIFICATION_STANDARD_DEVIATION, - GEOMETRY_SOURCE_DATA_ITEM_ID, + EVENT_COORDINATE_GEOMETRY_SOURCE, COORDINATE_FIELD_NAMES, } from '../constants/layers.js' import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' @@ -70,7 +70,7 @@ const addFeature = (acc, feature, { item, value }) => { // This function is modifiyng the config object before it's added to the redux store export const styleByDataItem = async (config, engine) => { const { styleDataItem } = config - if (styleDataItem.id === GEOMETRY_SOURCE_DATA_ITEM_ID) { + if (styleDataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE) { await styleByGeometrySource(config) } else if (styleDataItem.optionSet) { await styleByOptionSet(config, engine) @@ -106,7 +106,8 @@ const styleByGeometrySource = async (config) => { ) config.data = data.reduce((acc, feature) => { - const geometrySource = feature.properties[GEOMETRY_SOURCE_DATA_ITEM_ID] + const geometrySource = + feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] const item = geometrySource ? itemBySource[geometrySource] : null const isNoData = !geometrySource || !item diff --git a/src/util/versionToggle.js b/src/util/versionToggle.js new file mode 100644 index 0000000000..e5e8991ceb --- /dev/null +++ b/src/util/versionToggle.js @@ -0,0 +1,6 @@ +// VERSION-TOGGLE: pre-2.44, a fallbackCoordinateField pointed at a custom +// ORGANISATION_UNIT field crashes analytics (E7145) and geometrySource is +// never returned. 2.44+ fixes E7145 and returns geometrySource whenever a +// fallback is configured. +export const serverSupportsGeometrySource = (serverVersion) => + serverVersion?.minor >= 44 From a7a1f44244a1e94e6bd737d6e2fa4c33678e353e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 17 Sep 2026 12:08:35 +0200 Subject: [PATCH 16/26] fix: show display names instead of raw ids for custom geometry sources --- .../datatable/__tests__/useTableData.spec.jsx | 55 ++++++++++++- src/components/datatable/useTableData.js | 31 +++++-- src/components/map/layers/EventLayer.jsx | 21 +++-- src/components/map/layers/EventPopup.jsx | 18 ++--- src/loaders/__tests__/eventLoader.spec.js | 68 ++++++++++++++++ src/loaders/eventLoader.js | 40 ++++++++-- src/util/__tests__/styleByDataItem.spec.js | 80 ++++++++++++++++++- src/util/styleByDataItem.js | 15 +++- 8 files changed, 292 insertions(+), 36 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index a88837b90b..f22c9401b2 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -337,7 +337,60 @@ describe('useTableData headers', () => { }) expect(rows[0]).toContainEqual( expect.objectContaining({ - value: 'ougeometry', + value: 'Organisation unit location', + dataKey: 'geometrySource', + }) + ) + }) + + test('resolves a custom geometrySource id via layer.geometrySourceNames', () => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + geometrySourceNames: { + ougeometry: 'Organisation unit location', + abcDataElementUid1: 'My custom field', + }, + headers: [ + { + name: 'geometrySource', + column: 'Geometry source', + valueType: 'TEXT', + }, + ], + data: [ + { + properties: { + id: 'a9712323629', + type: 'Point', + ouname: 'Lumley Hospital', + eventdate: '2023-05-15 00:00:00.0', + geometrySource: 'abcDataElementUid1', + }, + }, + ], + } + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { rows } = result.current + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: 'My custom field', dataKey: 'geometrySource', }) ) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 8edd8f97a9..8ed8c1c30e 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -9,6 +9,7 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, EVENT_COORDINATE_GEOMETRY_SOURCE, + COORDINATE_FIELD_NAMES, } from '../../constants/layers.js' import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' @@ -218,6 +219,7 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { dataFilters, headers: layerHeaders, serverCluster, + geometrySourceNames, } = layer || EMPTY_LAYER const dataWithAggregations = useMemo(() => { @@ -242,14 +244,31 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { })) } + const names = geometrySourceNames || COORDINATE_FIELD_NAMES + return allData .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - index, - })) - }, [data, dataWithoutCoords, aggregations, serverCluster, layerType]) + .map((d, index) => { + const rawSource = + d.properties?.[EVENT_COORDINATE_GEOMETRY_SOURCE] + return { + ...(d.properties || d), + ...(rawSource != null && { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: + names[rawSource] ?? rawSource, + }), + ...aggregations[d.id], + index, + } + }) + }, [ + data, + dataWithoutCoords, + aggregations, + serverCluster, + layerType, + geometrySourceNames, + ]) const headers = useMemo(() => { if (errorCode.current) { diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index 5791716033..dca5ee6122 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -4,6 +4,7 @@ import React from 'react' import { EVENT_COLOR, EVENT_RADIUS, + EVENT_COORDINATE_GEOMETRY_SOURCE, LABEL_TEMPLATE_NAME_ONLY, LABEL_TEMPLATE_TOOLTIP_ONLY, } from '../../../constants/layers.js' @@ -62,6 +63,7 @@ class EventLayer extends Layer { labelFontSize, labelFontWeight, labelFontStyle, + geometrySourceNames, } = this.props const analyticsEngine = Analytics.getAnalytics(engine) @@ -88,11 +90,15 @@ class EventLayer extends Layer { const noDataLabel = i18n.t('No data') const formatItemValue = (feature, dataItem) => { const v = feature.properties[dataItem.id] + const resolved = + dataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE + ? geometrySourceNames?.[v] ?? v + : v return ( - (v != null && - v !== '' && + (resolved != null && + resolved !== '' && formatValueForDisplay({ - value: String(v), + value: String(resolved), valueType: dataItem.valueType, options: dataItem.options, keyAnalysisDigitGroupSeparator, @@ -250,8 +256,12 @@ class EventLayer extends Layer { } render() { - const { styleDataItem, nameProperty, keyAnalysisDigitGroupSeparator } = - this.props + const { + styleDataItem, + nameProperty, + keyAnalysisDigitGroupSeparator, + geometrySourceNames, + } = this.props const { popup, displayItems, eventCoordinateFieldName } = this.state return popup && displayItems ? ( @@ -262,6 +272,7 @@ class EventLayer extends Layer { keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator} displayItems={displayItems} eventCoordinateFieldName={eventCoordinateFieldName} + geometrySourceNames={geometrySourceNames} onClose={this.onPopupClose} /> ) : null diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index 88000db4a9..cc485bdb82 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -2,10 +2,7 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' -import { - COORDINATE_FIELD_NAMES, - EVENT_COORDINATE_GEOMETRY_SOURCE, -} from '../../../constants/layers.js' +import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../../constants/layers.js' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { formatDatetime, @@ -16,13 +13,8 @@ import { ORG_UNIT_QUERY } from '../../../util/orgUnits.js' import Popup from '../Popup.jsx' import styles from './styles/Popup.module.css' -const resolveGeometrySourceLabel = (geometrySource, displayItems) => { - if (COORDINATE_FIELD_NAMES[geometrySource]) { - return COORDINATE_FIELD_NAMES[geometrySource] - } - const item = displayItems?.find((i) => i.id === geometrySource) - return item?.name ?? geometrySource -} +const resolveGeometrySourceLabel = (geometrySource, geometrySourceNames) => + geometrySourceNames?.[geometrySource] ?? geometrySource const EVENTS_QUERY = { events: { @@ -74,6 +66,7 @@ const EventPopup = ({ keyAnalysisDigitGroupSeparator, displayItems, eventCoordinateFieldName, + geometrySourceNames, onClose, }) => { const [orgUnit, setOrgUnit] = useState() @@ -191,7 +184,7 @@ const EventPopup = ({ feature.properties[ EVENT_COORDINATE_GEOMETRY_SOURCE ], - displayItems + geometrySourceNames )} @@ -222,6 +215,7 @@ EventPopup.propTypes = { nameProperty: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, eventCoordinateFieldName: PropTypes.string, + geometrySourceNames: PropTypes.object, keyAnalysisDigitGroupSeparator: PropTypes.string, styleDataItem: PropTypes.object, } diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index fdd94299f1..1c2a9975b2 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -4,6 +4,10 @@ import { USER_ORG_UNIT_GRANDCHILDREN, } from '@dhis2/analytics' import { WARNING_OU_BOUNDARIES_FETCH_FAILED } from '../../constants/alerts.js' +import { + EVENT_COORDINATE_CASCADING, + COORDINATE_FIELD_NAMES, +} from '../../constants/layers.js' import { getUserOrgUnitIdsByKeyword } from '../../util/orgUnits.js' import { GEOFEATURES_QUERY, @@ -12,6 +16,7 @@ import { import { excludeEventsOutsideOrgUnits, isUnsupportedFallbackField, + getGeometrySourceNames, } from '../eventLoader.js' // [0,0]-[10,10] @@ -771,3 +776,66 @@ describe('isUnsupportedFallbackField', () => { expect(isUnsupportedFallbackField(undefined, { minor: 43 })).toBe(false) }) }) + +describe('getGeometrySourceNames', () => { + test('includes the built-in source names by default', () => { + expect(getGeometrySourceNames({})).toEqual(COORDINATE_FIELD_NAMES) + }) + + test('adds the main coordinate field name when it is a custom field', () => { + const names = getGeometrySourceNames({ + eventCoordinateField: 'customDataElement1', + coordinateField: { name: 'My custom field' }, + }) + + expect(names).toEqual({ + ...COORDINATE_FIELD_NAMES, + customDataElement1: 'My custom field', + }) + }) + + test('adds the fallback field name when it is a custom field', () => { + const names = getGeometrySourceNames({ + fallbackCoordinateField: 'customDataElement2', + fallbackField: { name: 'My fallback field' }, + }) + + expect(names).toEqual({ + ...COORDINATE_FIELD_NAMES, + customDataElement2: 'My fallback field', + }) + }) + + test('adds both custom main and fallback field names', () => { + const names = getGeometrySourceNames({ + eventCoordinateField: 'customDataElement1', + coordinateField: { name: 'My custom field' }, + fallbackCoordinateField: 'customDataElement2', + fallbackField: { name: 'My fallback field' }, + }) + + expect(names).toEqual({ + ...COORDINATE_FIELD_NAMES, + customDataElement1: 'My custom field', + customDataElement2: 'My fallback field', + }) + }) + + test('excludes the fallback field id when it is cascading', () => { + const names = getGeometrySourceNames({ + fallbackCoordinateField: EVENT_COORDINATE_CASCADING, + fallbackField: null, + }) + + expect(names).toEqual(COORDINATE_FIELD_NAMES) + }) + + test('does not add an entry when the custom field could not be resolved', () => { + const names = getGeometrySourceNames({ + eventCoordinateField: 'deletedDataElement', + coordinateField: undefined, + }) + + expect(names).toEqual(COORDINATE_FIELD_NAMES) + }) +}) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index fee411b30c..e8a93e6207 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -13,6 +13,7 @@ import { EVENT_COLOR, EVENT_RADIUS, EVENT_COORDINATE_CASCADING, + COORDINATE_FIELD_NAMES, } from '../constants/layers.js' import { numberValueTypes } from '../constants/valueTypes.js' import { @@ -102,6 +103,23 @@ export const isUnsupportedFallbackField = (fallbackField, serverVersion) => fallbackField?.valueType === 'ORGANISATION_UNIT' && !serverSupportsGeometrySource(serverVersion) +export const getGeometrySourceNames = ({ + eventCoordinateField, + coordinateField, + fallbackCoordinateField, + fallbackField, +}) => ({ + ...COORDINATE_FIELD_NAMES, + ...(eventCoordinateField && + coordinateField && { + [eventCoordinateField]: coordinateField.name, + }), + ...(fallbackField && + fallbackCoordinateField !== EVENT_COORDINATE_CASCADING && { + [fallbackCoordinateField]: fallbackField.name, + }), +}) + // Returns a promise const eventLoader = async ({ config: layerConfig, @@ -278,6 +296,21 @@ const loadEventLayer = async ({ alerts.push(fallbackCoordinateFieldUnsupportedAlert) } + const coordinateField = await loadEventCoordinateField({ + program, + programStage, + fieldId: eventCoordinateField, + engine, + displayNameProp, + }) + + config.geometrySourceNames = getGeometrySourceNames({ + eventCoordinateField, + coordinateField, + fallbackCoordinateField: config.fallbackCoordinateField, + fallbackField, + }) + const analyticsRequest = await getAnalyticsRequest(config, { analyticsEngine, nameProperty: displayNameProp, @@ -443,13 +476,6 @@ const loadEventLayer = async ({ // Coordinate field // ----- - const coordinateField = await loadEventCoordinateField({ - program, - programStage, - fieldId: eventCoordinateField, - engine, - displayNameProp, - }) if (coordinateField) { config.legend.coordinateFields = [coordinateField.name] } diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index 5baf91e5c1..8b34d5624e 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -1,4 +1,8 @@ -import { EVENT_COLOR, EVENT_RADIUS } from '../../constants/layers.js' +import { + EVENT_COLOR, + EVENT_RADIUS, + EVENT_COORDINATE_GEOMETRY_SOURCE, +} from '../../constants/layers.js' import { numberValueTypes, booleanValueTypes, @@ -605,6 +609,80 @@ describe('styleByDataItem', () => { }) }) + it('should resolve geometrySource names from config.geometrySourceNames', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: 'red', abcDataElementUid1: 'blue' }, + }, + geometrySourceNames: { + ougeometry: 'Organisation unit location', + abcDataElementUid1: 'My custom field', + }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: + 'abcDataElementUid1', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.data[0].properties).toMatchObject({ + value: 'Organisation unit location', + color: 'red', + }) + expect(result.data[1].properties).toMatchObject({ + value: 'My custom field', + color: 'blue', + }) + expect(result.legend.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Organisation unit location', + sourceId: 'ougeometry', + }), + expect.objectContaining({ + name: 'My custom field', + sourceId: 'abcDataElementUid1', + }), + ]) + ) + }) + + it('should fall back to COORDINATE_FIELD_NAMES for built-in sources when config.geometrySourceNames is absent', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: 'red' }, + }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.data[0].properties).toMatchObject({ + value: 'Organisation unit location', + color: 'red', + }) + }) + it('should include unclassified and no-data events when configured (option set)', async () => { const config = { styleDataItem: { diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 65d07c4703..c121f0d09d 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -86,14 +86,21 @@ export const styleByDataItem = async (config, engine) => { } const styleByGeometrySource = async (config) => { - const { styleDataItem, data, legend, eventPointRadius, noDataLegend } = - config + const { + styleDataItem, + data, + legend, + eventPointRadius, + noDataLegend, + geometrySourceNames, + } = config const { values } = styleDataItem + const names = geometrySourceNames || COORDINATE_FIELD_NAMES // Build legend items from stored color-per-source values legend.unit = i18n.t('Geometry source') legend.items = Object.entries(values || {}).map(([sourceId, color]) => ({ - name: COORDINATE_FIELD_NAMES[sourceId] ?? sourceId, + name: names[sourceId] ?? sourceId, color, sourceId, })) @@ -119,7 +126,7 @@ const styleByGeometrySource = async (config) => { item: isNoData ? noDataLegendItem : item, value: isNoData ? i18n.t('Not set') - : COORDINATE_FIELD_NAMES[geometrySource] ?? geometrySource, + : names[geometrySource] ?? geometrySource, }) return acc }, []) From c955fbb205723a9dd65961a533acf87c510800e8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 17 Sep 2026 12:09:37 +0200 Subject: [PATCH 17/26] fix: reset geometry-source style/label selection when fallback field is cleared --- src/reducers/__tests__/layerEdit.spec.js | 56 ++++++++++++++++++++++++ src/reducers/layerEdit.js | 13 ++++++ 2 files changed, 69 insertions(+) diff --git a/src/reducers/__tests__/layerEdit.spec.js b/src/reducers/__tests__/layerEdit.spec.js index 2f6a000341..3152d2dbc6 100644 --- a/src/reducers/__tests__/layerEdit.spec.js +++ b/src/reducers/__tests__/layerEdit.spec.js @@ -7,6 +7,7 @@ import { THEMATIC_CHOROPLETH, EE_BUFFER, NONE, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../../constants/layers.js' import { START_END_DATES } from '../../constants/periods.js' import { @@ -836,6 +837,61 @@ describe('layerEdit reducer', () => { expect(result.fallbackCoordinateField).toBe('field1') }) + + it('clears styleDataItem and labelDataItem set to geometry source when fallback is cleared', () => { + const result = layerEdit( + { + fallbackCoordinateField: 'field1', + styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + }, + { + type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + fieldId: NONE, + } + ) + + expect(result.styleDataItem).toBeNull() + expect(result.labelDataItem).toBeNull() + }) + + it('leaves unrelated styleDataItem and labelDataItem untouched when fallback is cleared', () => { + const result = layerEdit( + { + fallbackCoordinateField: 'field1', + styleDataItem: { id: 'someOtherField' }, + labelDataItem: { id: 'someOtherField' }, + }, + { + type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + fieldId: NONE, + } + ) + + expect(result.styleDataItem).toEqual({ id: 'someOtherField' }) + expect(result.labelDataItem).toEqual({ id: 'someOtherField' }) + }) + + it('leaves styleDataItem and labelDataItem set to geometry source untouched when fallback is changed (not cleared)', () => { + const result = layerEdit( + { + fallbackCoordinateField: 'field1', + styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + }, + { + type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + fieldId: 'cascading', + } + ) + + expect(result.styleDataItem).toEqual({ + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + }) + expect(result.labelDataItem).toEqual({ + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + }) + }) }) it('sets eventClustering', () => { diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index d5a05ee037..ba036ef5ff 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -7,6 +7,7 @@ import { THEMATIC_CHOROPLETH, EE_BUFFER, NONE, + EVENT_COORDINATE_GEOMETRY_SOURCE, } from '../constants/layers.js' import { START_END_DATES } from '../constants/periods.js' import { @@ -372,6 +373,18 @@ const layerEdit = (state = null, action) => { if (action.fieldId === NONE) { delete newState.fallbackCoordinateField delete newState.fallbackCoordinateFieldType + if ( + newState.styleDataItem?.id === + EVENT_COORDINATE_GEOMETRY_SOURCE + ) { + newState.styleDataItem = null + } + if ( + newState.labelDataItem?.id === + EVENT_COORDINATE_GEOMETRY_SOURCE + ) { + newState.labelDataItem = null + } } else { newState.fallbackCoordinateField = action.fieldId newState.fallbackCoordinateFieldType = action.fieldType From a980deb6961d84336994c3718ce6fe5ee02bc95a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 10:26:14 +0200 Subject: [PATCH 18/26] chore: sonarqube clean-up --- i18n/en.pot | 28 +++--- src/components/dataItem/CoordinateField.jsx | 93 +++++++++++-------- .../__tests__/CoordinateField.spec.jsx | 8 +- src/components/legend/Legend.jsx | 4 +- src/util/__tests__/versionToggle.spec.js | 44 ++++++++- src/util/versionToggle.js | 8 ++ 6 files changed, 123 insertions(+), 62 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 897c362358..3ac6ecf5dd 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-15T16:43:35.708Z\n" -"PO-Revision-Date: 2026-09-15T16:43:35.708Z\n" +"POT-Creation-Date: 2026-09-21T08:26:16.426Z\n" +"PO-Revision-Date: 2026-09-21T08:26:16.426Z\n" msgid "2020" msgstr "2020" @@ -83,6 +83,18 @@ msgstr "Yes" msgid "No" msgstr "No" +msgid "Select a program to see additional coordinate options" +msgstr "Select a program to see additional coordinate options" + +msgid "Event > enrollment > tracked entity > org unit coordinate" +msgstr "Event > enrollment > tracked entity > org unit coordinate" + +msgid "Event > org unit coordinate" +msgstr "Event > org unit coordinate" + +msgid "Select a program stage to see additional coordinate options" +msgstr "Select a program stage to see additional coordinate options" + msgid "None" msgstr "None" @@ -101,18 +113,6 @@ msgstr "Enrollment location" msgid "Tracked entity location" msgstr "Tracked entity location" -msgid "Event > enrollment > tracked entity > org unit coordinate" -msgstr "Event > enrollment > tracked entity > org unit coordinate" - -msgid "Event > org unit coordinate" -msgstr "Event > org unit coordinate" - -msgid "Select a program stage to see additional coordinate options" -msgstr "Select a program stage to see additional coordinate options" - -msgid "Select a program to see additional coordinate options" -msgstr "Select a program to see additional coordinate options" - msgid "Fallback coordinate field" msgstr "Fallback coordinate field" diff --git a/src/components/dataItem/CoordinateField.jsx b/src/components/dataItem/CoordinateField.jsx index 2dc16143d8..b2a36a313c 100644 --- a/src/components/dataItem/CoordinateField.jsx +++ b/src/components/dataItem/CoordinateField.jsx @@ -14,10 +14,56 @@ import { coordinateValueTypes, ouValueTypes, } from '../../constants/valueTypes.js' -import { serverSupportsGeometrySource } from '../../util/versionToggle.js' +import { + serverSupportsGeometrySource, + serverSupportsOrgUnitCoordinateField, +} from '../../util/versionToggle.js' import { SelectField } from '../core/index.js' import { useEventDataItems } from './EventDataItemsProvider.jsx' +const getIncludeTypes = (isFallback, serverVersion) => { + const includeTypes = [...coordinateValueTypes] + + if (isFallback) { + // VERSION-TOGGLE: fallbackCoordinateField pointed at a custom + // ORGANISATION_UNIT field crashes pre-2.44 - see util/versionToggle.js + if (serverSupportsGeometrySource(serverVersion)) { + includeTypes.push(...ouValueTypes) + } + return includeTypes + } + + // VERSION-TOGGLE: ORGANISATION_UNIT isn't a valid coordinate field + // pre-2.40.8/2.41.4/2.42 - see util/versionToggle.js + if (serverSupportsOrgUnitCoordinateField(serverVersion)) { + includeTypes.push(...ouValueTypes) + } + + return includeTypes +} + +const getHelpText = ({ program, programStage, value, trackedEntityType }) => { + if (!program) { + return i18n.t('Select a program to see additional coordinate options') + } + + if (value === EVENT_COORDINATE_CASCADING) { + return trackedEntityType?.id + ? i18n.t( + 'Event > enrollment > tracked entity > org unit coordinate' + ) + : i18n.t('Event > org unit coordinate') + } + + if (!programStage && trackedEntityType?.id) { + return i18n.t( + 'Select a program stage to see additional coordinate options' + ) + } + + return null +} + const CoordinateField = ({ value, type, @@ -30,26 +76,7 @@ const CoordinateField = ({ }) => { const { serverVersion } = useConfig() const isFallback = !!eventCoordinateField - - const includeTypes = [...coordinateValueTypes] - if (isFallback) { - // VERSION-TOGGLE: fallbackCoordinateField pointed at a custom - // ORGANISATION_UNIT field crashes pre-2.44 - see util/versionToggle.js - if (serverSupportsGeometrySource(serverVersion)) { - includeTypes.push(...ouValueTypes) - } - } else if ( - // VERSION-TOGGLE - // https://dhis2.atlassian.net/browse/DHIS2-19010 and: - // - [2.40.8] https://github.com/dhis2/dhis2-core/commit/f2286a5aa70b2957bd24925776e9394cd67d44c1 - // - [2.41.4] https://github.com/dhis2/dhis2-core/commit/19f29f27385cfae1c7fac234439f49987ec2abe4 - // - [2.42.0] https://github.com/dhis2/dhis2-core/commit/e5b29f4f1dbee791be9e6befb8a304151a1661c9 - (serverVersion.minor === 40 && serverVersion.patch >= 8) || - (serverVersion.minor === 41 && serverVersion.patch >= 4) || - serverVersion.minor >= 42 - ) { - includeTypes.push(...ouValueTypes) - } + const includeTypes = getIncludeTypes(isFallback, serverVersion) const { eventDataItems, @@ -109,24 +136,12 @@ const CoordinateField = ({ : fields }, [trackedEntityType, eventDataItems, eventCoordinateField, isFallback]) - let helpText = null - if (program) { - if (value === EVENT_COORDINATE_CASCADING) { - helpText = trackedEntityType?.id - ? i18n.t( - 'Event > enrollment > tracked entity > org unit coordinate' - ) - : i18n.t('Event > org unit coordinate') - } else if (!programStage && trackedEntityType?.id) { - helpText = i18n.t( - 'Select a program stage to see additional coordinate options' - ) - } - } else { - helpText = i18n.t( - 'Select a program to see additional coordinate options' - ) - } + const helpText = getHelpText({ + program, + programStage, + value, + trackedEntityType, + }) // Initiate type when editing saved layer useEffect(() => { diff --git a/src/components/dataItem/__tests__/CoordinateField.spec.jsx b/src/components/dataItem/__tests__/CoordinateField.spec.jsx index 637cb7023e..04ca6cb3d5 100644 --- a/src/components/dataItem/__tests__/CoordinateField.spec.jsx +++ b/src/components/dataItem/__tests__/CoordinateField.spec.jsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, act } from '@testing-library/react' +import { render, screen, fireEvent } from '@testing-library/react' import React from 'react' import CoordinateField from '../CoordinateField.jsx' @@ -87,11 +87,7 @@ describe('CoordinateField', () => { ) - await act(async () => { - await fireEvent.click( - screen.getByTestId('dhis2-uicore-select-input') - ) - }) + fireEvent.click(screen.getByTestId('dhis2-uicore-select-input')) const labels = screen .getAllByTestId('dhis2-uicore-singleselectoption') diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 1189a9504d..116dcaac8d 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -343,8 +343,8 @@ const Legend = ({ {Array.isArray(coordinateFields) && (
{i18n.t('Coordinate field')}
- {coordinateFields.map((coordinateField, index) => ( -
+ {coordinateFields.map((coordinateField) => ( +
{coordinateField} {fallbackCoordinateField && ` (${i18n.t( diff --git a/src/util/__tests__/versionToggle.spec.js b/src/util/__tests__/versionToggle.spec.js index 163a3b6e82..670d39d09c 100644 --- a/src/util/__tests__/versionToggle.spec.js +++ b/src/util/__tests__/versionToggle.spec.js @@ -1,4 +1,7 @@ -import { serverSupportsGeometrySource } from '../versionToggle.js' +import { + serverSupportsGeometrySource, + serverSupportsOrgUnitCoordinateField, +} from '../versionToggle.js' describe('serverSupportsGeometrySource', () => { it('returns false below 2.44', () => { @@ -17,3 +20,42 @@ describe('serverSupportsGeometrySource', () => { expect(serverSupportsGeometrySource(undefined)).toBe(false) }) }) + +describe('serverSupportsOrgUnitCoordinateField', () => { + it('returns false on 2.40 below patch 8', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 40, patch: 7 }) + ).toBe(false) + }) + + it('returns true on 2.40 from patch 8', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 40, patch: 8 }) + ).toBe(true) + }) + + it('returns false on 2.41 below patch 4', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 41, patch: 3 }) + ).toBe(false) + }) + + it('returns true on 2.41 from patch 4', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 41, patch: 4 }) + ).toBe(true) + }) + + it('returns true on 2.42 and above regardless of patch', () => { + expect( + serverSupportsOrgUnitCoordinateField({ minor: 42, patch: 0 }) + ).toBe(true) + expect( + serverSupportsOrgUnitCoordinateField({ minor: 45, patch: 0 }) + ).toBe(true) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsOrgUnitCoordinateField(undefined)).toBe(false) + }) +}) diff --git a/src/util/versionToggle.js b/src/util/versionToggle.js index e5e8991ceb..5b0c5f4fc4 100644 --- a/src/util/versionToggle.js +++ b/src/util/versionToggle.js @@ -4,3 +4,11 @@ // fallback is configured. export const serverSupportsGeometrySource = (serverVersion) => serverVersion?.minor >= 44 + +// VERSION-TOGGLE: ORGANISATION_UNIT-type fields became usable as a +// (non-fallback) coordinate field in 2.40.8, 2.41.4, and 2.42.0 - see +// DHIS2-19010. +export const serverSupportsOrgUnitCoordinateField = (serverVersion) => + (serverVersion?.minor === 40 && serverVersion.patch >= 8) || + (serverVersion?.minor === 41 && serverVersion.patch >= 4) || + serverVersion?.minor >= 42 From 85d7a630dce900527e3eb8119bbc9960ed0aaedf Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 11:10:12 +0200 Subject: [PATCH 19/26] chore: test refactor --- .../datatable/__tests__/useTableData.spec.jsx | 163 +++++++----------- 1 file changed, 65 insertions(+), 98 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index f22c9401b2..8e098e1a79 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -289,112 +289,79 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) - test('includes a geometrySource column when present in the response headers', () => { - const store = { - aggregations: {}, - } - const layer = { - layer: 'event', - dataFilters: null, - isExtended: true, - headers: [ - { - name: 'geometrySource', - column: 'Geometry source', - valueType: 'TEXT', - }, - ], - data: [ - { - properties: { - id: 'a9712323629', - type: 'Point', - ouname: 'Lumley Hospital', - eventdate: '2023-05-15 00:00:00.0', - geometrySource: 'ougeometry', - }, - }, - ], - } - const { result } = renderHook( - () => - useTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - }), - { - wrapper: ({ children }) => ( - {children} - ), - } - ) - const { headers, rows } = result.current - expect(headers).toContainEqual({ - name: 'Geometry source', - dataKey: 'geometrySource', - type: 'string', - }) - expect(rows[0]).toContainEqual( - expect.objectContaining({ - value: 'Organisation unit location', - dataKey: 'geometrySource', - }) - ) - }) - - test('resolves a custom geometrySource id via layer.geometrySourceNames', () => { - const store = { - aggregations: {}, - } - const layer = { - layer: 'event', - dataFilters: null, - isExtended: true, + it.each([ + { + desc: 'built-in source, falls back to COORDINATE_FIELD_NAMES', + geometrySourceNames: undefined, + geometrySource: 'ougeometry', + expectedValue: 'Organisation unit location', + }, + { + desc: 'custom source, resolved via layer.geometrySourceNames', geometrySourceNames: { ougeometry: 'Organisation unit location', abcDataElementUid1: 'My custom field', }, - headers: [ - { - name: 'geometrySource', - column: 'Geometry source', - valueType: 'TEXT', - }, - ], - data: [ - { - properties: { - id: 'a9712323629', - type: 'Point', - ouname: 'Lumley Hospital', - eventdate: '2023-05-15 00:00:00.0', - geometrySource: 'abcDataElementUid1', + geometrySource: 'abcDataElementUid1', + expectedValue: 'My custom field', + }, + ])( + 'includes a geometrySource column with a resolved name: $desc', + ({ geometrySourceNames, geometrySource, expectedValue }) => { + const store = { + aggregations: {}, + } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + ...(geometrySourceNames && { geometrySourceNames }), + headers: [ + { + name: 'geometrySource', + column: 'Geometry source', + valueType: 'TEXT', }, - }, - ], - } - const { result } = renderHook( - () => - useTableData({ - layer, - sortField: 'name', - sortDirection: 'asc', - }), - { - wrapper: ({ children }) => ( - {children} - ), + ], + data: [ + { + properties: { + id: 'a9712323629', + type: 'Point', + ouname: 'Lumley Hospital', + eventdate: '2023-05-15 00:00:00.0', + geometrySource, + }, + }, + ], } - ) - const { rows } = result.current - expect(rows[0]).toContainEqual( - expect.objectContaining({ - value: 'My custom field', + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + const { headers, rows } = result.current + expect(headers).toContainEqual({ + name: 'Geometry source', dataKey: 'geometrySource', + type: 'string', }) - ) - }) + expect(rows[0]).toContainEqual( + expect.objectContaining({ + value: expectedValue, + dataKey: 'geometrySource', + }) + ) + } + ) test('treats NUMBER header with optionSet as string type', () => { const store = { aggregations: {} } From d270da54ad90b93997a12d78f51b9046d96fb47d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 12:36:36 +0200 Subject: [PATCH 20/26] fix: default geometry source to the main field when no fallback is configured --- src/components/map/layers/EventLayer.jsx | 16 +++++++++--- src/components/map/layers/EventPopup.jsx | 29 ++++++---------------- src/util/__tests__/styleByDataItem.spec.js | 24 ++++++++++++++++++ src/util/coordinatesName.js | 3 +++ src/util/styleByDataItem.js | 12 ++++++--- 5 files changed, 55 insertions(+), 29 deletions(-) diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index dca5ee6122..525fc1a2b0 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -5,11 +5,15 @@ import { EVENT_COLOR, EVENT_RADIUS, EVENT_COORDINATE_GEOMETRY_SOURCE, + EVENT_COORDINATE_DEFAULT, LABEL_TEMPLATE_NAME_ONLY, LABEL_TEMPLATE_TOOLTIP_ONLY, } from '../../../constants/layers.js' import { getContrastColor } from '../../../util/colors.js' -import { loadEventCoordinateField } from '../../../util/coordinatesName.js' +import { + loadEventCoordinateField, + resolveGeometrySourceName, +} from '../../../util/coordinatesName.js' import { getAnalyticsRequest, EVENT_PROGRAM_STAGE_DATA_ELEMENTS_QUERY, @@ -90,10 +94,14 @@ class EventLayer extends Layer { const noDataLabel = i18n.t('No data') const formatItemValue = (feature, dataItem) => { const v = feature.properties[dataItem.id] - const resolved = + // No fallback configured means the point can only be from the main field. + const geometrySource = dataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE - ? geometrySourceNames?.[v] ?? v - : v + ? v ?? eventCoordinateField ?? EVENT_COORDINATE_DEFAULT + : null + const resolved = geometrySource + ? resolveGeometrySourceName(geometrySource, geometrySourceNames) + : v return ( (resolved != null && resolved !== '' && diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index cc485bdb82..ebaac6140f 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -3,6 +3,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { EVENT_COORDINATE_GEOMETRY_SOURCE } from '../../../constants/layers.js' +import { resolveGeometrySourceName } from '../../../util/coordinatesName.js' import { EVENT_ID_FIELD } from '../../../util/geojson.js' import { formatDatetime, @@ -13,9 +14,6 @@ import { ORG_UNIT_QUERY } from '../../../util/orgUnits.js' import Popup from '../Popup.jsx' import styles from './styles/Popup.module.css' -const resolveGeometrySourceLabel = (geometrySource, geometrySourceNames) => - geometrySourceNames?.[geometrySource] ?? geometrySource - const EVENTS_QUERY = { events: { resource: 'tracker/events', @@ -141,6 +139,11 @@ const EventPopup = ({ }) } + const geometrySource = feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] + const coordinateFieldLabel = geometrySource + ? resolveGeometrySourceName(geometrySource, geometrySourceNames) + : eventCoordinateFieldName || i18n.t('Event location') + return ( - - {eventCoordinateFieldName || - i18n.t('Event location')} - + {coordinateFieldLabel} {formatCoordinate(coord)} )} - {feature.properties[ - EVENT_COORDINATE_GEOMETRY_SOURCE - ] && ( - - {i18n.t('Geometry source')} - - {resolveGeometrySourceLabel( - feature.properties[ - EVENT_COORDINATE_GEOMETRY_SOURCE - ], - geometrySourceNames - )} - - - )} {orgUnit && ( {i18n.t('Organisation unit')} diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index 8b34d5624e..462d36d0dc 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -683,6 +683,30 @@ describe('styleByDataItem', () => { }) }) + it('should default geometrySource to the main coordinate field when no fallback is configured', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: 'red' }, + }, + eventCoordinateField: 'ougeometry', + geometrySourceNames: { + ougeometry: 'Organisation unit location', + }, + data: [ + { properties: {} }, // no fallback configured - backend never sends geometrySource + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.data[0].properties).toMatchObject({ + value: 'Organisation unit location', + color: 'red', + }) + }) + it('should include unclassified and no-data events when configured (option set)', async () => { const config = { styleDataItem: { diff --git a/src/util/coordinatesName.js b/src/util/coordinatesName.js index 1246d28f62..862410bff4 100644 --- a/src/util/coordinatesName.js +++ b/src/util/coordinatesName.js @@ -64,3 +64,6 @@ export const loadEventCoordinateField = async ({ return undefined } + +export const resolveGeometrySourceName = (id, geometrySourceNames) => + geometrySourceNames?.[id] ?? id diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index c121f0d09d..5f09aeac76 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -6,10 +6,12 @@ import { CLASSIFICATION_LOGARITHMIC, CLASSIFICATION_STANDARD_DEVIATION, EVENT_COORDINATE_GEOMETRY_SOURCE, + EVENT_COORDINATE_DEFAULT, COORDINATE_FIELD_NAMES, } from '../constants/layers.js' import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' import { cssColor } from '../util/colors.js' +import { resolveGeometrySourceName } from '../util/coordinatesName.js' import { OPTION_SET_QUERY, LEGEND_SET_QUERY } from '../util/requests.js' import { getLegendItemForValue } from './classify.js' import { getAutomaticLegendItems, getPredefinedLegendItems } from './legend.js' @@ -93,6 +95,7 @@ const styleByGeometrySource = async (config) => { eventPointRadius, noDataLegend, geometrySourceNames, + eventCoordinateField, } = config const { values } = styleDataItem const names = geometrySourceNames || COORDINATE_FIELD_NAMES @@ -100,7 +103,7 @@ const styleByGeometrySource = async (config) => { // Build legend items from stored color-per-source values legend.unit = i18n.t('Geometry source') legend.items = Object.entries(values || {}).map(([sourceId, color]) => ({ - name: names[sourceId] ?? sourceId, + name: resolveGeometrySourceName(sourceId, names), color, sourceId, })) @@ -113,8 +116,11 @@ const styleByGeometrySource = async (config) => { ) config.data = data.reduce((acc, feature) => { + // No fallback configured means the point can only be from the main field. const geometrySource = - feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] + feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] ?? + eventCoordinateField ?? + EVENT_COORDINATE_DEFAULT const item = geometrySource ? itemBySource[geometrySource] : null const isNoData = !geometrySource || !item @@ -126,7 +132,7 @@ const styleByGeometrySource = async (config) => { item: isNoData ? noDataLegendItem : item, value: isNoData ? i18n.t('Not set') - : names[geometrySource] ?? geometrySource, + : resolveGeometrySourceName(geometrySource, names), }) return acc }, []) From 1a0971a7097afdfaac94375dd765aac77ac59add Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 14:50:03 +0200 Subject: [PATCH 21/26] fix: keep geometry-source legend and colors in sync with the Data tab --- src/actions/__tests__/layerEdit.spec.js | 7 + src/actions/layerEdit.js | 5 + .../dataItem/EventDataItemsProvider.jsx | 14 +- .../dataItem/GeometrySourceStyle.jsx | 78 ++-------- src/constants/actionTypes.js | 2 + src/constants/layers.js | 7 + src/loaders/eventLoader.js | 12 +- src/reducers/__tests__/layerEdit.spec.js | 107 ++++++++++++- src/reducers/layerEdit.js | 39 ++++- src/util/__tests__/styleByDataItem.spec.js | 142 ++++++++++++++++++ src/util/coordinatesName.js | 67 ++++++++- src/util/event.js | 10 ++ src/util/styleByDataItem.js | 56 ++++--- 13 files changed, 452 insertions(+), 94 deletions(-) diff --git a/src/actions/__tests__/layerEdit.spec.js b/src/actions/__tests__/layerEdit.spec.js index 2ae909d06d..08e5a8e8de 100644 --- a/src/actions/__tests__/layerEdit.spec.js +++ b/src/actions/__tests__/layerEdit.spec.js @@ -66,6 +66,7 @@ import { setEarthEnginePeriod, setFeatureStyle, setLabelDataItem, + setHasTrackedEntityType, } from '../layerEdit.js' describe('layerEdit simple action creators', () => { @@ -460,6 +461,12 @@ describe('layerEdit simple action creators', () => { type: types.LAYER_EDIT_LABEL_DATA_ITEM_ID_SET, rest: { item: { id: 'de1' } }, }, + { + creator: setHasTrackedEntityType, + args: [true], + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + rest: { value: true }, + }, ])( '$creator.name creates the expected action', ({ creator, args, type, rest }) => { diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 9278c82a01..86bc898b4a 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -81,6 +81,11 @@ export const setGeometrySourceStyle = (value, color) => ({ color, }) +export const setHasTrackedEntityType = (value) => ({ + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + value, +}) + // Set thematic map type (choropleth, bubble map) export const setThematicMapType = (type) => ({ type: types.LAYER_EDIT_THEMATIC_MAP_TYPE_SET, diff --git a/src/components/dataItem/EventDataItemsProvider.jsx b/src/components/dataItem/EventDataItemsProvider.jsx index 5720d7b808..c967bfbd59 100644 --- a/src/components/dataItem/EventDataItemsProvider.jsx +++ b/src/components/dataItem/EventDataItemsProvider.jsx @@ -1,5 +1,7 @@ import PropTypes from 'prop-types' -import React, { createContext, useContext, useMemo } from 'react' +import React, { createContext, useContext, useEffect, useMemo } from 'react' +import { useDispatch } from 'react-redux' +import { setHasTrackedEntityType } from '../../actions/layerEdit.js' import { useProgramStageDataElements } from '../../hooks/useProgramStageDataElements.js' import { useProgramTrackedEntityAttributes } from '../../hooks/useProgramTrackedEntityAttributes.js' import { combineDataItems } from '../../util/analytics.js' @@ -9,8 +11,10 @@ const EventDataItemsCtx = createContext(null) // Fetches program stage data elements and program tracked entity attributes // once, and shares them with every useEventDataItems() call within — avoids // duplicate network requests when several components need the same -// program/programStage's data items, each with their own type filter. +// program/programStage's data items, each with their own type filter. Also +// mirrors hasTrackedEntityType into redux for the reducer to read. const EventDataItemsProvider = ({ programId, programStageId, children }) => { + const dispatch = useDispatch() const { dataElements, loading: dataElementsLoading } = useProgramStageDataElements({ programStageId }) const { @@ -19,6 +23,12 @@ const EventDataItemsProvider = ({ programId, programStageId, children }) => { loading: attributesLoading, } = useProgramTrackedEntityAttributes({ programId }) + useEffect(() => { + if (trackedEntityType !== null) { + dispatch(setHasTrackedEntityType(!!trackedEntityType?.id)) + } + }, [trackedEntityType, dispatch]) + const value = useMemo( () => ({ dataElements, diff --git a/src/components/dataItem/GeometrySourceStyle.jsx b/src/components/dataItem/GeometrySourceStyle.jsx index da5ed21711..8b042ba9b9 100644 --- a/src/components/dataItem/GeometrySourceStyle.jsx +++ b/src/components/dataItem/GeometrySourceStyle.jsx @@ -1,16 +1,11 @@ -import React, { useEffect } from 'react' +import React from 'react' import { useDispatch, useSelector } from 'react-redux' import { setGeometrySourceStyle } from '../../actions/layerEdit.js' -import { qualitativeColors } from '../../constants/colors.js' +import { COORDINATE_FIELD_NAMES } from '../../constants/layers.js' import { - COORDINATE_FIELD_NAMES, - EVENT_COORDINATE_CASCADING, - EVENT_COORDINATE_DEFAULT, - EVENT_COORDINATE_ENROLLMENT, - EVENT_COORDINATE_ORG_UNIT, - EVENT_COORDINATE_TRACKED_ENTITY, - NONE, -} from '../../constants/layers.js' + getDefaultGeometrySourceColor, + getPossibleGeometrySources, +} from '../../util/coordinatesName.js' import OptionStyle from '../optionSet/OptionStyle.jsx' import { useEventDataItems } from './EventDataItemsProvider.jsx' @@ -18,38 +13,6 @@ const style = { marginTop: 20, } -// Expands a coordinate field id to the set of geometrySource values the backend can return. -// For 'cascading', this is the full cascade chain based on whether the program has a TEI type. -const expandField = (fieldId, hasTei) => { - if (fieldId === EVENT_COORDINATE_CASCADING) { - return hasTei - ? [ - EVENT_COORDINATE_ENROLLMENT, - EVENT_COORDINATE_DEFAULT, - EVENT_COORDINATE_TRACKED_ENTITY, - EVENT_COORDINATE_ORG_UNIT, - ] - : [EVENT_COORDINATE_DEFAULT, EVENT_COORDINATE_ORG_UNIT] - } - return [fieldId] -} - -const getPossibleSources = ( - eventCoordinateField, - fallbackCoordinateField, - hasTei -) => { - const main = expandField( - eventCoordinateField ?? EVENT_COORDINATE_DEFAULT, - hasTei - ) - const fallback = - fallbackCoordinateField && fallbackCoordinateField !== NONE - ? expandField(fallbackCoordinateField, hasTei) - : [] - return [...new Set([...main, ...fallback])] -} - const GeometrySourceStyle = () => { const dispatch = useDispatch() const styleDataItem = useSelector((state) => state.layerEdit.styleDataItem) @@ -65,7 +28,7 @@ const GeometrySourceStyle = () => { const hasTei = !!trackedEntityType?.id - const sources = getPossibleSources( + const sources = getPossibleGeometrySources( eventCoordinateField, fallbackCoordinateField, hasTei @@ -73,27 +36,6 @@ const GeometrySourceStyle = () => { const values = styleDataItem?.values - useEffect(() => { - if (eventDataItems === null) { - return - } - const usedColors = new Set(Object.values(values || {})) - const availableColors = qualitativeColors.filter( - (c) => !usedColors.has(c) - ) - let nextColorIndex = 0 - sources.forEach((sourceId) => { - if (!values?.[sourceId]) { - const color = - availableColors[nextColorIndex] ?? - qualitativeColors[nextColorIndex % qualitativeColors.length] - nextColorIndex++ - dispatch(setGeometrySourceStyle(sourceId, color)) - } - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sources.join(','), eventDataItems === null, dispatch]) - // Wait for event data items to load before rendering, so DE/TEA UIDs // are never shown raw (trackedEntityType also drives sources via hasTei) if (eventDataItems === null || !values) { @@ -114,7 +56,13 @@ const GeometrySourceStyle = () => { dispatch(setGeometrySourceStyle(sourceId, color)) } diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 35992187ce..612f96244e 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -83,6 +83,8 @@ export const LAYER_EDIT_STYLE_DATA_ITEM_BOOLEAN_SET = 'LAYER_EDIT_STYLE_DATA_ITEM_BOOLEAN_SET' export const LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET = 'LAYER_EDIT_STYLE_DATA_ITEM_GEOMETRY_SOURCE_SET' +export const LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET = + 'LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET' export const LAYER_EDIT_THEMATIC_MAP_TYPE_SET = 'LAYER_EDIT_THEMATIC_MAP_TYPE_SET' export const LAYER_EDIT_CLASSIFICATION_SET = 'LAYER_EDIT_CLASSIFICATION_SET' diff --git a/src/constants/layers.js b/src/constants/layers.js index a4d03e2524..dcbdfc0237 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -1,4 +1,5 @@ import i18n from '@dhis2/d2-i18n' +import { qualitativeColors } from './colors.js' export const VECTOR_STYLE = 'vectorStyle' export const TILE_LAYER = 'tileLayer' @@ -115,6 +116,12 @@ export const COORDINATE_FIELD_NAMES = { [EVENT_COORDINATE_ENROLLMENT]: i18n.t('Enrollment location'), [EVENT_COORDINATE_TRACKED_ENTITY]: i18n.t('Tracked entity location'), } +export const GEOMETRY_SOURCE_COLORS = { + [EVENT_COORDINATE_DEFAULT]: qualitativeColors[0], + [EVENT_COORDINATE_ORG_UNIT]: qualitativeColors[1], + [EVENT_COORDINATE_ENROLLMENT]: qualitativeColors[2], + [EVENT_COORDINATE_TRACKED_ENTITY]: qualitativeColors[3], +} export const EVENT_CENTROID_DEFAULT = [ EVENT_COORDINATE_DEFAULT, EVENT_COORDINATE_ENROLLMENT, diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index e8a93e6207..2cf3da4b4f 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -13,6 +13,7 @@ import { EVENT_COLOR, EVENT_RADIUS, EVENT_COORDINATE_CASCADING, + EVENT_COORDINATE_GEOMETRY_SOURCE, COORDINATE_FIELD_NAMES, } from '../constants/layers.js' import { numberValueTypes } from '../constants/valueTypes.js' @@ -25,7 +26,10 @@ import { } from '../util/analytics.js' import { cssColor, getContrastColor } from '../util/colors.js' import { parseJsonConfig } from '../util/config.js' -import { loadEventCoordinateField } from '../util/coordinatesName.js' +import { + loadEventCoordinateField, + loadHasTrackedEntityType, +} from '../util/coordinatesName.js' import { getAnalyticsRequest, loadData } from '../util/event.js' import { getBounds, @@ -389,6 +393,12 @@ const loadEventLayer = async ({ } if (styleDataItem) { + if (styleDataItem.id === EVENT_COORDINATE_GEOMETRY_SOURCE) { + config.hasTrackedEntityType = await loadHasTrackedEntityType({ + program, + engine, + }) + } await styleByDataItem(config, engine) } diff --git a/src/reducers/__tests__/layerEdit.spec.js b/src/reducers/__tests__/layerEdit.spec.js index 3152d2dbc6..ecb59cb359 100644 --- a/src/reducers/__tests__/layerEdit.spec.js +++ b/src/reducers/__tests__/layerEdit.spec.js @@ -885,13 +885,114 @@ describe('layerEdit reducer', () => { } ) - expect(result.styleDataItem).toEqual({ - id: EVENT_COORDINATE_GEOMETRY_SOURCE, - }) + expect(result.styleDataItem.id).toBe( + EVENT_COORDINATE_GEOMETRY_SOURCE + ) expect(result.labelDataItem).toEqual({ id: EVENT_COORDINATE_GEOMETRY_SOURCE, }) }) + + it('syncs styleDataItem.values to the new fallback when styling by geometry source', () => { + const result = layerEdit( + { + eventCoordinateField: 'ougeometry', + fallbackCoordinateField: 'field1', + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: '#111', field1: '#222' }, + }, + }, + { + type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + fieldId: 'field2', + fieldType: 'COORDINATE', + } + ) + + expect(result.styleDataItem.values).toEqual( + expect.objectContaining({ + ougeometry: '#111', + field2: expect.any(String), + }) + ) + expect(result.styleDataItem.values.field1).toBeUndefined() + }) + }) + + describe('LAYER_EDIT_EVENT_COORDINATE_FIELD_SET', () => { + it('leaves styleDataItem untouched when not styling by geometry source', () => { + const result = layerEdit( + { styleDataItem: { id: 'someOtherField' } }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_SET, + fieldId: 'field1', + fieldType: 'COORDINATE', + } + ) + + expect(result.styleDataItem).toEqual({ id: 'someOtherField' }) + }) + + it('syncs styleDataItem.values to the new main field when styling by geometry source', () => { + const result = layerEdit( + { + eventCoordinateField: 'ougeometry', + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { ougeometry: '#111' }, + }, + }, + { + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_SET, + fieldId: 'field1', + fieldType: 'COORDINATE', + } + ) + + expect(result.styleDataItem.values).toEqual({ + field1: expect.any(String), + }) + }) + }) + + describe('LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET', () => { + it('sets hasTrackedEntityType', () => { + const result = layerEdit( + {}, + { + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + value: true, + } + ) + + expect(result.hasTrackedEntityType).toBe(true) + }) + + it('syncs styleDataItem.values when styling by geometry source (cascading, TEI type resolved)', () => { + const result = layerEdit( + { + fallbackCoordinateField: 'cascading', + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: {}, + }, + }, + { + type: types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET, + value: true, + } + ) + + expect(Object.keys(result.styleDataItem.values)).toEqual( + expect.arrayContaining([ + 'pigeometry', + 'psigeometry', + 'teigeometry', + 'ougeometry', + ]) + ) + }) }) it('sets eventClustering', () => { diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index ba036ef5ff..ba4ef5ae69 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -20,6 +20,35 @@ import { splitFilterColumns, compactFilterColumns, } from '../util/analytics.js' +import { + getDefaultGeometrySourceColor, + getPossibleGeometrySources, +} from '../util/coordinatesName.js' + +const syncGeometrySourceValues = (state) => { + if (state.styleDataItem?.id !== EVENT_COORDINATE_GEOMETRY_SOURCE) { + return state.styleDataItem + } + const sources = getPossibleGeometrySources( + state.eventCoordinateField, + state.fallbackCoordinateField, + state.hasTrackedEntityType + ) + const prevValues = state.styleDataItem.values || {} + return { + ...state.styleDataItem, + values: Object.fromEntries( + sources.map((id) => [ + id, + prevValues[id] ?? + getDefaultGeometrySourceColor(id, { + eventCoordinateField: state.eventCoordinateField, + fallbackCoordinateField: state.fallbackCoordinateField, + }), + ]) + ), + } +} const layerEdit = (state = null, action) => { let newState @@ -361,11 +390,13 @@ const layerEdit = (state = null, action) => { return newState case types.LAYER_EDIT_EVENT_COORDINATE_FIELD_SET: - return { + newState = { ...state, eventCoordinateField: action.fieldId, eventCoordinateFieldType: action.fieldType, } + newState.styleDataItem = syncGeometrySourceValues(newState) + return newState case types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET: newState = { ...state } @@ -390,6 +421,12 @@ const layerEdit = (state = null, action) => { newState.fallbackCoordinateFieldType = action.fieldType } + newState.styleDataItem = syncGeometrySourceValues(newState) + return newState + + case types.LAYER_EDIT_HAS_TRACKED_ENTITY_TYPE_SET: + newState = { ...state, hasTrackedEntityType: action.value } + newState.styleDataItem = syncGeometrySourceValues(newState) return newState case types.LAYER_EDIT_EVENT_CLUSTERING_SET: diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index 462d36d0dc..6fab459dda 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -1,7 +1,11 @@ +import { qualitativeColors } from '../../constants/colors.js' import { EVENT_COLOR, EVENT_RADIUS, EVENT_COORDINATE_GEOMETRY_SOURCE, + EVENT_COORDINATE_ORG_UNIT, + EVENT_COORDINATE_CASCADING, + GEOMETRY_SOURCE_COLORS, } from '../../constants/layers.js' import { numberValueTypes, @@ -615,6 +619,8 @@ describe('styleByDataItem', () => { id: EVENT_COORDINATE_GEOMETRY_SOURCE, values: { ougeometry: 'red', abcDataElementUid1: 'blue' }, }, + eventCoordinateField: 'abcDataElementUid1', + fallbackCoordinateField: 'ougeometry', geometrySourceNames: { ougeometry: 'Organisation unit location', abcDataElementUid1: 'My custom field', @@ -665,6 +671,7 @@ describe('styleByDataItem', () => { id: EVENT_COORDINATE_GEOMETRY_SOURCE, values: { ougeometry: 'red' }, }, + eventCoordinateField: 'ougeometry', data: [ { properties: { @@ -707,6 +714,141 @@ describe('styleByDataItem', () => { }) }) + it('should exclude stale styleDataItem.values entries no longer present in the data', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { + ougeometry: 'red', + abcDataElementUid1: 'blue', + }, + }, + eventCoordinateField: 'ougeometry', + geometrySourceNames: { + ougeometry: 'Organisation unit location', + }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.legend.items).toHaveLength(1) + expect(result.legend.items[0]).toMatchObject({ + sourceId: 'ougeometry', + name: 'Organisation unit location', + }) + }) + + it('should order the legend with the main field first, then fallback in cascading order', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: { + ougeometry: 'c1', + teigeometry: 'c2', + psigeometry: 'c3', + pigeometry: 'c4', + customDataElement1: 'c5', + }, + }, + eventCoordinateField: 'customDataElement1', + fallbackCoordinateField: EVENT_COORDINATE_CASCADING, + hasTrackedEntityType: true, + geometrySourceNames: { + customDataElement1: 'My custom field', + }, + data: [], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.legend.items.map((i) => i.sourceId)).toEqual([ + 'customDataElement1', + 'pigeometry', + 'psigeometry', + 'teigeometry', + 'ougeometry', + ]) + }) + + it('should give a source missing from styleDataItem.values a default color without adding a legend entry', async () => { + const config = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: {}, + }, + geometrySourceNames: { + ougeometry: 'Organisation unit location', + }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config) + + expect(result.legend.items).toHaveLength(0) + expect(result.data[0].properties.color).toBe( + GEOMETRY_SOURCE_COLORS[EVENT_COORDINATE_ORG_UNIT] + ) + }) + + it('should assign a source the same color regardless of which other sources are present', async () => { + const baseConfig = { + styleDataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + values: {}, + }, + eventCoordinateField: 'customDataElement1', + geometrySourceNames: { + customDataElement1: 'My custom field', + }, + } + + const resultAlone = await styleByDataItem({ + ...baseConfig, + legend: { items: [] }, + data: [{ properties: {} }], // resolves to customDataElement1 (no fallback) + }) + const resultAlongsideOthers = await styleByDataItem({ + ...baseConfig, + legend: { items: [] }, + data: [ + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'ougeometry', + }, + }, + { + properties: { + [EVENT_COORDINATE_GEOMETRY_SOURCE]: 'pigeometry', + }, + }, + { properties: {} }, // resolves to customDataElement1 + ], + }) + + const colorAlone = resultAlone.data[0].properties.color + const colorAlongsideOthers = + resultAlongsideOthers.data[2].properties.color + + expect(colorAlone).toBe(colorAlongsideOthers) + expect(qualitativeColors).toContain(colorAlone) + }) + it('should include unclassified and no-data events when configured (option set)', async () => { const config = { styleDataItem: { diff --git a/src/util/coordinatesName.js b/src/util/coordinatesName.js index 862410bff4..83834683da 100644 --- a/src/util/coordinatesName.js +++ b/src/util/coordinatesName.js @@ -1,7 +1,18 @@ -import { COORDINATE_FIELD_NAMES } from '../constants/layers.js' +import { qualitativeColors } from '../constants/colors.js' +import { + COORDINATE_FIELD_NAMES, + GEOMETRY_SOURCE_COLORS, + EVENT_COORDINATE_CASCADING, + EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_ENROLLMENT, + EVENT_COORDINATE_ORG_UNIT, + EVENT_COORDINATE_TRACKED_ENTITY, + NONE, +} from '../constants/layers.js' import { EVENT_PROGRAM_STAGE_DATA_ELEMENTS_QUERY, EVENT_PROGRAM_ATTRIBUTES_QUERY, + EVENT_PROGRAM_TRACKED_ENTITY_TYPE_QUERY, } from '../util/event.js' // Resolves a coordinate field id to its name/valueType - only custom DE/TEA @@ -67,3 +78,57 @@ export const loadEventCoordinateField = async ({ export const resolveGeometrySourceName = (id, geometrySourceNames) => geometrySourceNames?.[id] ?? id + +export const getDefaultGeometrySourceColor = ( + id, + { eventCoordinateField, fallbackCoordinateField } +) => { + if (GEOMETRY_SOURCE_COLORS[id]) { + return GEOMETRY_SOURCE_COLORS[id] + } + if (id === eventCoordinateField) { + return qualitativeColors[4] + } + if (id === fallbackCoordinateField) { + return qualitativeColors[5] + } + return qualitativeColors[0] +} + +const expandField = (fieldId, hasTei) => { + if (fieldId === EVENT_COORDINATE_CASCADING) { + return hasTei + ? [ + EVENT_COORDINATE_ENROLLMENT, + EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_TRACKED_ENTITY, + EVENT_COORDINATE_ORG_UNIT, + ] + : [EVENT_COORDINATE_DEFAULT, EVENT_COORDINATE_ORG_UNIT] + } + return [fieldId] +} + +export const loadHasTrackedEntityType = async ({ program, engine }) => { + const { program: programData } = await engine.query( + EVENT_PROGRAM_TRACKED_ENTITY_TYPE_QUERY, + { variables: { id: program.id } } + ) + return !!programData?.trackedEntityType?.id +} + +export const getPossibleGeometrySources = ( + eventCoordinateField, + fallbackCoordinateField, + hasTei +) => { + const main = expandField( + eventCoordinateField ?? EVENT_COORDINATE_DEFAULT, + hasTei + ) + const fallback = + fallbackCoordinateField && fallbackCoordinateField !== NONE + ? expandField(fallbackCoordinateField, hasTei) + : [] + return [...new Set([...main, ...fallback])] +} diff --git a/src/util/event.js b/src/util/event.js index 9c0e9cb1e7..af93fbf563 100644 --- a/src/util/event.js +++ b/src/util/event.js @@ -33,6 +33,16 @@ export const EVENT_PROGRAM_ATTRIBUTES_QUERY = { }, } +export const EVENT_PROGRAM_TRACKED_ENTITY_TYPE_QUERY = { + program: { + resource: 'programs', + id: ({ id }) => id, + params: { + fields: 'trackedEntityType[id]', + }, + }, +} + // Empty filter sometimes returned for saved maps // Dimension without filter and empty items array returns false const isValidDimension = ({ dimension, filter, items }) => diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 5f09aeac76..6f866540c9 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -11,7 +11,11 @@ import { } from '../constants/layers.js' import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' import { cssColor } from '../util/colors.js' -import { resolveGeometrySourceName } from '../util/coordinatesName.js' +import { + getDefaultGeometrySourceColor, + getPossibleGeometrySources, + resolveGeometrySourceName, +} from '../util/coordinatesName.js' import { OPTION_SET_QUERY, LEGEND_SET_QUERY } from '../util/requests.js' import { getLegendItemForValue } from './classify.js' import { getAutomaticLegendItems, getPredefinedLegendItems } from './legend.js' @@ -93,26 +97,31 @@ const styleByGeometrySource = async (config) => { data, legend, eventPointRadius, - noDataLegend, - geometrySourceNames, eventCoordinateField, + fallbackCoordinateField, + hasTrackedEntityType, } = config const { values } = styleDataItem - const names = geometrySourceNames || COORDINATE_FIELD_NAMES + const names = config.geometrySourceNames || COORDINATE_FIELD_NAMES + const possibleSources = getPossibleGeometrySources( + eventCoordinateField, + fallbackCoordinateField, + hasTrackedEntityType + ) - // Build legend items from stored color-per-source values + // Ordered main field first, then fallback (cascading order if applicable). legend.unit = i18n.t('Geometry source') - legend.items = Object.entries(values || {}).map(([sourceId, color]) => ({ - name: resolveGeometrySourceName(sourceId, names), - color, - sourceId, - })) - - const { noDataLegendItem } = addSpecialLegendItems(legend, { noDataLegend }) + legend.items = possibleSources + .filter((sourceId) => values?.[sourceId]) + .map((sourceId) => ({ + name: resolveGeometrySourceName(sourceId, names), + color: values[sourceId], + sourceId, + })) stampLegendItems(legend.items, eventPointRadius) const itemBySource = Object.fromEntries( - legend.items.filter((i) => i.sourceId).map((i) => [i.sourceId, i]) + legend.items.map((i) => [i.sourceId, i]) ) config.data = data.reduce((acc, feature) => { @@ -121,18 +130,23 @@ const styleByGeometrySource = async (config) => { feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] ?? eventCoordinateField ?? EVENT_COORDINATE_DEFAULT - const item = geometrySource ? itemBySource[geometrySource] : null - const isNoData = !geometrySource || !item - if (isNoData && !noDataLegendItem) { - return acc + // Backfills a color without adding a legend entry for it. + if (!itemBySource[geometrySource]) { + itemBySource[geometrySource] = { + color: getDefaultGeometrySourceColor(geometrySource, { + eventCoordinateField, + fallbackCoordinateField, + }), + radius: eventPointRadius || EVENT_RADIUS, + count: 0, + colorGroup: legend.items.length, + } } addFeature(acc, feature, { - item: isNoData ? noDataLegendItem : item, - value: isNoData - ? i18n.t('Not set') - : resolveGeometrySourceName(geometrySource, names), + item: itemBySource[geometrySource], + value: resolveGeometrySourceName(geometrySource, names), }) return acc }, []) From 4c7fed57d74bd1aca7f4a54ee6a2aeec24ef04c0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 16:14:36 +0200 Subject: [PATCH 22/26] fix: persist eventCoordinateFieldFallback and fix geometry/coordinate source styling --- cypress/elements/event_layer.js | 4 +- i18n/en.pot | 8 +- src/actions/__tests__/layerEdit.spec.js | 6 +- src/actions/layerEdit.js | 4 +- src/components/dataItem/CoordinateField.jsx | 2 +- .../dataItem/GeometrySourceStyle.jsx | 8 +- src/components/dataItem/StyleByDataItem.jsx | 2 +- src/components/edit/event/EventDialog.jsx | 18 ++-- .../edit/event/LabelFieldSelect.jsx | 2 +- src/components/legend/Legend.jsx | 8 +- .../legend/__tests__/Legend.spec.jsx | 2 +- src/constants/actionTypes.js | 4 +- src/constants/layers.js | 6 +- src/loaders/__tests__/eventLoader.spec.js | 6 +- src/loaders/eventLoader.js | 37 +++++--- src/reducers/__tests__/layerEdit.spec.js | 87 ++++++++++++------- src/reducers/layerEdit.js | 31 +++---- src/util/__tests__/favorites.spec.js | 50 +++++++++++ src/util/__tests__/styleByDataItem.spec.js | 6 +- src/util/__tests__/versionToggle.spec.js | 27 ++++++ src/util/coordinatesName.js | 16 ++-- src/util/event.js | 8 +- src/util/favorites.js | 33 +++++-- src/util/styleByDataItem.js | 8 +- src/util/versionToggle.js | 12 ++- 25 files changed, 261 insertions(+), 134 deletions(-) diff --git a/cypress/elements/event_layer.js b/cypress/elements/event_layer.js index e0b2c7c25d..4bf5365707 100644 --- a/cypress/elements/event_layer.js +++ b/cypress/elements/event_layer.js @@ -85,10 +85,10 @@ export class EventLayer extends Layer { selectFallbackCoordinate(coordinate) { cy.getByDataTest( - 'fallbackcoordinatefield-content', + 'eventcoordinatefieldfallback-content', EXTENDED_TIMEOUT ).should(($el) => expect($el.text().trim().length).to.be.greaterThan(0)) - selectPopperOption('fallbackcoordinatefield-content', coordinate) + selectPopperOption('eventcoordinatefieldfallback-content', coordinate) return this } diff --git a/i18n/en.pot b/i18n/en.pot index 3ac6ecf5dd..fc4dc7bab3 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-21T08:26:16.426Z\n" -"PO-Revision-Date: 2026-09-21T08:26:16.426Z\n" +"POT-Creation-Date: 2026-09-21T14:14:37.990Z\n" +"PO-Revision-Date: 2026-09-21T14:14:37.990Z\n" msgid "2020" msgstr "2020" @@ -125,8 +125,8 @@ msgstr "Include events with no data" msgid "Include unclassified events" msgstr "Include unclassified events" -msgid "Geometry source" -msgstr "Geometry source" +msgid "Coordinate source" +msgstr "Coordinate source" msgid "Previously selected value not available in list: {{id}}" msgstr "Previously selected value not available in list: {{id}}" diff --git a/src/actions/__tests__/layerEdit.spec.js b/src/actions/__tests__/layerEdit.spec.js index 08e5a8e8de..48e8853669 100644 --- a/src/actions/__tests__/layerEdit.spec.js +++ b/src/actions/__tests__/layerEdit.spec.js @@ -19,7 +19,7 @@ import { setLegendIsolated, setEventStatus, setEventCoordinateField, - setFallbackCoordinateField, + setEventCoordinateFieldFallback, setEventClustering, setCountFeaturesWithoutCoordinates, setCountEventsOutsideOrgUnits, @@ -186,9 +186,9 @@ describe('layerEdit simple action creators', () => { rest: { fieldId: 'field1', fieldType: 'COORDINATE' }, }, { - creator: setFallbackCoordinateField, + creator: setEventCoordinateFieldFallback, args: ['field1'], - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, rest: { fieldId: 'field1' }, }, { diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 86bc898b4a..070c853d9d 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -127,8 +127,8 @@ export const setEventCoordinateField = (fieldId, fieldType) => ({ }) // Set fallback coordinate field -export const setFallbackCoordinateField = (fieldId, fieldType) => ({ - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, +export const setEventCoordinateFieldFallback = (fieldId, fieldType) => ({ + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId, fieldType, }) diff --git a/src/components/dataItem/CoordinateField.jsx b/src/components/dataItem/CoordinateField.jsx index b2a36a313c..df551ac5e0 100644 --- a/src/components/dataItem/CoordinateField.jsx +++ b/src/components/dataItem/CoordinateField.jsx @@ -25,7 +25,7 @@ const getIncludeTypes = (isFallback, serverVersion) => { const includeTypes = [...coordinateValueTypes] if (isFallback) { - // VERSION-TOGGLE: fallbackCoordinateField pointed at a custom + // VERSION-TOGGLE: eventCoordinateFieldFallback pointed at a custom // ORGANISATION_UNIT field crashes pre-2.44 - see util/versionToggle.js if (serverSupportsGeometrySource(serverVersion)) { includeTypes.push(...ouValueTypes) diff --git a/src/components/dataItem/GeometrySourceStyle.jsx b/src/components/dataItem/GeometrySourceStyle.jsx index 8b042ba9b9..3829e85f71 100644 --- a/src/components/dataItem/GeometrySourceStyle.jsx +++ b/src/components/dataItem/GeometrySourceStyle.jsx @@ -19,8 +19,8 @@ const GeometrySourceStyle = () => { const eventCoordinateField = useSelector( (state) => state.layerEdit.eventCoordinateField ) - const fallbackCoordinateField = useSelector( - (state) => state.layerEdit.fallbackCoordinateField + const eventCoordinateFieldFallback = useSelector( + (state) => state.layerEdit.eventCoordinateFieldFallback ) const { eventDataItems, trackedEntityType } = useEventDataItems({ includeTypes: ['COORDINATE', 'ORGANISATION_UNIT'], @@ -30,7 +30,7 @@ const GeometrySourceStyle = () => { const sources = getPossibleGeometrySources( eventCoordinateField, - fallbackCoordinateField, + eventCoordinateFieldFallback, hasTei ) @@ -60,7 +60,7 @@ const GeometrySourceStyle = () => { values[sourceId] ?? getDefaultGeometrySourceColor(sourceId, { eventCoordinateField, - fallbackCoordinateField, + eventCoordinateFieldFallback, }) } onChange={(color) => diff --git a/src/components/dataItem/StyleByDataItem.jsx b/src/components/dataItem/StyleByDataItem.jsx index 82975e46e9..5102d6d67c 100644 --- a/src/components/dataItem/StyleByDataItem.jsx +++ b/src/components/dataItem/StyleByDataItem.jsx @@ -20,7 +20,7 @@ const excludeTypes = [ const GEOMETRY_SOURCE_ITEM = { id: EVENT_COORDINATE_GEOMETRY_SOURCE, - name: i18n.t('Geometry source'), + name: i18n.t('Coordinate source'), } // Style by data item is used by event layer, and can be reused for TEI layer in the future. diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 23418ac0a2..b597b61569 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -13,7 +13,7 @@ import { setEventClustering, setEventPointColor, setEventPointRadius, - setFallbackCoordinateField, + setEventCoordinateFieldFallback, setPeriods, setPeriodType, setStartDate, @@ -80,8 +80,8 @@ const EventDialog = ({ eventPointColor, eventPointRadius, eventStatus, - fallbackCoordinateField = 'none', - fallbackCoordinateFieldType = 'none', + eventCoordinateFieldFallback = 'none', + eventCoordinateFieldFallbackType = 'none', filters, legendIsolated, legendSet, @@ -323,21 +323,21 @@ const EventDialog = ({ dispatch( - setFallbackCoordinateField( + setEventCoordinateFieldFallback( fieldId, fieldType ) ) } className={styles.select} - dataTest="fallbackcoordinatefield" + dataTest="eventcoordinatefieldfallback" /> { diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 116dcaac8d..9c37ddb8a7 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -82,7 +82,7 @@ const Legend = ({ description, filters, coordinateFields, - fallbackCoordinateField, + eventCoordinateFieldFallback, groups, unit, items, @@ -346,10 +346,10 @@ const Legend = ({ {coordinateFields.map((coordinateField) => (
{coordinateField} - {fallbackCoordinateField && + {eventCoordinateFieldFallback && ` (${i18n.t( 'fallback' - )}: ${fallbackCoordinateField})`} + )}: ${eventCoordinateFieldFallback})`}
))}
@@ -406,10 +406,10 @@ Legend.propTypes = { coordinateFields: PropTypes.array, decimalPlaces: PropTypes.number, description: PropTypes.string, + eventCoordinateFieldFallback: PropTypes.string, eventsOutsideOrgUnitsCount: PropTypes.number, eventsWithoutCoordinatesCount: PropTypes.number, explanation: PropTypes.array, - fallbackCoordinateField: PropTypes.string, filters: PropTypes.array, groups: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), isPlugin: PropTypes.bool, diff --git a/src/components/legend/__tests__/Legend.spec.jsx b/src/components/legend/__tests__/Legend.spec.jsx index 676de132bb..5193f5a1e0 100644 --- a/src/components/legend/__tests__/Legend.spec.jsx +++ b/src/components/legend/__tests__/Legend.spec.jsx @@ -20,7 +20,7 @@ describe('Legend coordinate field / fallback display', () => { render( ) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 612f96244e..33b079f984 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -157,8 +157,8 @@ export const LAYER_EDIT_COUNT_EVENTS_OUTSIDE_OU_SET = 'LAYER_EDIT_COUNT_EVENTS_OUTSIDE_OU_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 = - 'LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET' +export const LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET = + 'LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET' export const LAYER_EDIT_EARTH_ENGINE_PERIOD_SET = 'LAYER_EDIT_EARTH_ENGINE_PERIOD_SET' export const LAYER_EDIT_LABEL_DATA_ITEM_ID_SET = diff --git a/src/constants/layers.js b/src/constants/layers.js index dcbdfc0237..3096346879 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -118,9 +118,9 @@ export const COORDINATE_FIELD_NAMES = { } export const GEOMETRY_SOURCE_COLORS = { [EVENT_COORDINATE_DEFAULT]: qualitativeColors[0], - [EVENT_COORDINATE_ORG_UNIT]: qualitativeColors[1], - [EVENT_COORDINATE_ENROLLMENT]: qualitativeColors[2], - [EVENT_COORDINATE_TRACKED_ENTITY]: qualitativeColors[3], + [EVENT_COORDINATE_ENROLLMENT]: qualitativeColors[1], + [EVENT_COORDINATE_TRACKED_ENTITY]: qualitativeColors[2], + [EVENT_COORDINATE_ORG_UNIT]: qualitativeColors[10], } export const EVENT_CENTROID_DEFAULT = [ EVENT_COORDINATE_DEFAULT, diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 1c2a9975b2..780a89faf3 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -796,7 +796,7 @@ describe('getGeometrySourceNames', () => { test('adds the fallback field name when it is a custom field', () => { const names = getGeometrySourceNames({ - fallbackCoordinateField: 'customDataElement2', + eventCoordinateFieldFallback: 'customDataElement2', fallbackField: { name: 'My fallback field' }, }) @@ -810,7 +810,7 @@ describe('getGeometrySourceNames', () => { const names = getGeometrySourceNames({ eventCoordinateField: 'customDataElement1', coordinateField: { name: 'My custom field' }, - fallbackCoordinateField: 'customDataElement2', + eventCoordinateFieldFallback: 'customDataElement2', fallbackField: { name: 'My fallback field' }, }) @@ -823,7 +823,7 @@ describe('getGeometrySourceNames', () => { test('excludes the fallback field id when it is cascading', () => { const names = getGeometrySourceNames({ - fallbackCoordinateField: EVENT_COORDINATE_CASCADING, + eventCoordinateFieldFallback: EVENT_COORDINATE_CASCADING, fallbackField: null, }) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 2cf3da4b4f..b0a6f9584e 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -93,7 +93,7 @@ const unknownErrorAlert = { code: CUSTOM_ALERT, message: i18n.t('An unknown error occurred while reading layer data'), } -const fallbackCoordinateFieldUnsupportedAlert = { +const eventCoordinateFieldFallbackUnsupportedAlert = { warning: true, code: CUSTOM_ALERT, message: i18n.t( @@ -110,7 +110,7 @@ export const isUnsupportedFallbackField = (fallbackField, serverVersion) => export const getGeometrySourceNames = ({ eventCoordinateField, coordinateField, - fallbackCoordinateField, + eventCoordinateFieldFallback, fallbackField, }) => ({ ...COORDINATE_FIELD_NAMES, @@ -119,8 +119,8 @@ export const getGeometrySourceNames = ({ [eventCoordinateField]: coordinateField.name, }), ...(fallbackField && - fallbackCoordinateField !== EVENT_COORDINATE_CASCADING && { - [fallbackCoordinateField]: fallbackField.name, + eventCoordinateFieldFallback !== EVENT_COORDINATE_CASCADING && { + [eventCoordinateFieldFallback]: fallbackField.name, }), }) @@ -201,6 +201,7 @@ const loadEventLayer = async ({ unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, labelDataItem, + eventCoordinateFieldFallback: eventCoordinateFieldFallbackFromConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -236,6 +237,16 @@ const loadEventLayer = async ({ if (noDataLegendFromConfig) { config.noDataLegend = noDataLegendFromConfig } + // VERSION-TOGGLE: eventCoordinateFieldFallback isn't a schema field + // pre-2.43 - see util/versionToggle.js. Read it back from the config + // blob if it wasn't saved as a native property. + if ( + eventCoordinateFieldFallbackFromConfig && + !config.eventCoordinateFieldFallback + ) { + config.eventCoordinateFieldFallback = + eventCoordinateFieldFallbackFromConfig + } if (config.noDataColor) { config.noDataLegend = { ...noDataLegendFromConfig, @@ -283,21 +294,21 @@ const loadEventLayer = async ({ // ----- let fallbackField = - config.fallbackCoordinateField && - config.fallbackCoordinateField !== EVENT_COORDINATE_CASCADING + config.eventCoordinateFieldFallback && + config.eventCoordinateFieldFallback !== EVENT_COORDINATE_CASCADING ? await loadEventCoordinateField({ program, programStage, - fieldId: config.fallbackCoordinateField, + fieldId: config.eventCoordinateFieldFallback, engine, displayNameProp, }) : null if (isUnsupportedFallbackField(fallbackField, serverVersion)) { - delete config.fallbackCoordinateField + delete config.eventCoordinateFieldFallback fallbackField = null - alerts.push(fallbackCoordinateFieldUnsupportedAlert) + alerts.push(eventCoordinateFieldFallbackUnsupportedAlert) } const coordinateField = await loadEventCoordinateField({ @@ -311,7 +322,7 @@ const loadEventLayer = async ({ config.geometrySourceNames = getGeometrySourceNames({ eventCoordinateField, coordinateField, - fallbackCoordinateField: config.fallbackCoordinateField, + eventCoordinateFieldFallback: config.eventCoordinateFieldFallback, fallbackField, }) @@ -490,10 +501,10 @@ const loadEventLayer = async ({ config.legend.coordinateFields = [coordinateField.name] } - if (config.fallbackCoordinateField === EVENT_COORDINATE_CASCADING) { - config.legend.fallbackCoordinateField = i18n.t('Cascading') + if (config.eventCoordinateFieldFallback === EVENT_COORDINATE_CASCADING) { + config.legend.eventCoordinateFieldFallback = i18n.t('Cascading') } else if (fallbackField) { - config.legend.fallbackCoordinateField = fallbackField.name + config.legend.eventCoordinateFieldFallback = fallbackField.name } // Legend items & explanation diff --git a/src/reducers/__tests__/layerEdit.spec.js b/src/reducers/__tests__/layerEdit.spec.js index ecb59cb359..d0e4c33d96 100644 --- a/src/reducers/__tests__/layerEdit.spec.js +++ b/src/reducers/__tests__/layerEdit.spec.js @@ -463,6 +463,27 @@ describe('layerEdit reducer', () => { expect(result.styleDataItem).toBe(dataItem) }) + it('populates styleDataItem.values immediately when geometry source is selected', () => { + const result = layerEdit( + { + eventCoordinateField: 'customDataElement1', + eventCoordinateFieldFallback: 'customDataElement2', + }, + { + type: types.LAYER_EDIT_STYLE_DATA_ITEM_SET, + dataItem: { + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + name: 'Geometry source', + }, + } + ) + + expect(result.styleDataItem.values).toEqual({ + customDataElement1: expect.any(String), + customDataElement2: expect.any(String), + }) + }) + it('sets option-set options and clears method/classes/colorScale', () => { const state = { method: CLASSIFICATION_EQUAL_INTERVALS, @@ -813,57 +834,61 @@ describe('layerEdit reducer', () => { expect(result.eventCoordinateFieldType).toBe('COORDINATE') }) - describe('LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET', () => { - it('deletes fallbackCoordinateField when set to NONE', () => { + describe('LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET', () => { + it('deletes eventCoordinateFieldFallback when set to NONE', () => { const result = layerEdit( - { fallbackCoordinateField: 'field1' }, + { eventCoordinateFieldFallback: 'field1' }, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: NONE, } ) - expect(result.fallbackCoordinateField).toBeUndefined() + expect(result.eventCoordinateFieldFallback).toBeUndefined() }) - it('sets fallbackCoordinateField for any other value', () => { + it('sets eventCoordinateFieldFallback for any other value', () => { const result = layerEdit( {}, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: 'field1', } ) - expect(result.fallbackCoordinateField).toBe('field1') + expect(result.eventCoordinateFieldFallback).toBe('field1') }) - it('clears styleDataItem and labelDataItem set to geometry source when fallback is cleared', () => { + it('leaves styleDataItem and labelDataItem set to geometry source when fallback is cleared', () => { const result = layerEdit( { - fallbackCoordinateField: 'field1', + eventCoordinateFieldFallback: 'field1', styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, }, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: NONE, } ) - expect(result.styleDataItem).toBeNull() - expect(result.labelDataItem).toBeNull() + expect(result.styleDataItem.id).toBe( + EVENT_COORDINATE_GEOMETRY_SOURCE + ) + expect(result.labelDataItem).toEqual({ + id: EVENT_COORDINATE_GEOMETRY_SOURCE, + }) }) it('leaves unrelated styleDataItem and labelDataItem untouched when fallback is cleared', () => { const result = layerEdit( { - fallbackCoordinateField: 'field1', + eventCoordinateFieldFallback: 'field1', styleDataItem: { id: 'someOtherField' }, labelDataItem: { id: 'someOtherField' }, }, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: NONE, } ) @@ -875,12 +900,12 @@ describe('layerEdit reducer', () => { it('leaves styleDataItem and labelDataItem set to geometry source untouched when fallback is changed (not cleared)', () => { const result = layerEdit( { - fallbackCoordinateField: 'field1', + eventCoordinateFieldFallback: 'field1', styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, labelDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, }, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: 'cascading', } ) @@ -897,14 +922,14 @@ describe('layerEdit reducer', () => { const result = layerEdit( { eventCoordinateField: 'ougeometry', - fallbackCoordinateField: 'field1', + eventCoordinateFieldFallback: 'field1', styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE, values: { ougeometry: '#111', field1: '#222' }, }, }, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: 'field2', fieldType: 'COORDINATE', } @@ -972,7 +997,7 @@ describe('layerEdit reducer', () => { it('syncs styleDataItem.values when styling by geometry source (cascading, TEI type resolved)', () => { const result = layerEdit( { - fallbackCoordinateField: 'cascading', + eventCoordinateFieldFallback: 'cascading', styleDataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE, values: {}, @@ -1481,32 +1506,34 @@ describe('layerEdit reducer', () => { expect(layerEdit(state, { type: 'UNKNOWN' })).toBe(state) }) - it('sets fallbackCoordinateField and fallbackCoordinateFieldType together', () => { + it('sets eventCoordinateFieldFallback and eventCoordinateFieldFallbackType together', () => { const result = layerEdit( {}, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: 'deId123456', fieldType: 'ORGANISATION_UNIT', } ) - expect(result.fallbackCoordinateField).toBe('deId123456') - expect(result.fallbackCoordinateFieldType).toBe('ORGANISATION_UNIT') + expect(result.eventCoordinateFieldFallback).toBe('deId123456') + expect(result.eventCoordinateFieldFallbackType).toBe( + 'ORGANISATION_UNIT' + ) }) - it('clears fallbackCoordinateField and fallbackCoordinateFieldType when set to none', () => { + it('clears eventCoordinateFieldFallback and eventCoordinateFieldFallbackType when set to none', () => { const state = { - fallbackCoordinateField: 'deId123456', - fallbackCoordinateFieldType: 'ORGANISATION_UNIT', + eventCoordinateFieldFallback: 'deId123456', + eventCoordinateFieldFallbackType: 'ORGANISATION_UNIT', } const result = layerEdit(state, { - type: types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET, + type: types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET, fieldId: 'none', }) - expect(result.fallbackCoordinateField).toBeUndefined() - expect(result.fallbackCoordinateFieldType).toBeUndefined() + expect(result.eventCoordinateFieldFallback).toBeUndefined() + expect(result.eventCoordinateFieldFallbackType).toBeUndefined() }) }) diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index ba4ef5ae69..b3933c0bbd 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -31,7 +31,7 @@ const syncGeometrySourceValues = (state) => { } const sources = getPossibleGeometrySources( state.eventCoordinateField, - state.fallbackCoordinateField, + state.eventCoordinateFieldFallback, state.hasTrackedEntityType ) const prevValues = state.styleDataItem.values || {} @@ -43,7 +43,8 @@ const syncGeometrySourceValues = (state) => { prevValues[id] ?? getDefaultGeometrySourceColor(id, { eventCoordinateField: state.eventCoordinateField, - fallbackCoordinateField: state.fallbackCoordinateField, + eventCoordinateFieldFallback: + state.eventCoordinateFieldFallback, }), ]) ), @@ -242,10 +243,12 @@ const layerEdit = (state = null, action) => { } case types.LAYER_EDIT_STYLE_DATA_ITEM_SET: - return { + newState = { ...state, styleDataItem: action.dataItem, } + newState.styleDataItem = syncGeometrySourceValues(newState) + return newState // Set options to data element option set case types.LAYER_EDIT_STYLE_DATA_ITEM_OPTIONS_SET: @@ -398,27 +401,15 @@ const layerEdit = (state = null, action) => { newState.styleDataItem = syncGeometrySourceValues(newState) return newState - case types.LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET: + case types.LAYER_EDIT_EVENT_COORDINATE_FIELD_FALLBACK_SET: newState = { ...state } if (action.fieldId === NONE) { - delete newState.fallbackCoordinateField - delete newState.fallbackCoordinateFieldType - if ( - newState.styleDataItem?.id === - EVENT_COORDINATE_GEOMETRY_SOURCE - ) { - newState.styleDataItem = null - } - if ( - newState.labelDataItem?.id === - EVENT_COORDINATE_GEOMETRY_SOURCE - ) { - newState.labelDataItem = null - } + delete newState.eventCoordinateFieldFallback + delete newState.eventCoordinateFieldFallbackType } else { - newState.fallbackCoordinateField = action.fieldId - newState.fallbackCoordinateFieldType = action.fieldType + newState.eventCoordinateFieldFallback = action.fieldId + newState.eventCoordinateFieldFallbackType = action.fieldType } newState.styleDataItem = syncGeometrySourceValues(newState) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 5db9075a14..b95e2f2e77 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -375,6 +375,56 @@ describe('cleanMapConfig', () => { expect(view).not.toHaveProperty('noDataLegend') }) + test('keeps eventCoordinateFieldFallback as a native property on 2.43+', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'event', + name: 'Test', + opacity: 1, + eventCoordinateFieldFallback: 'cascading', + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + serverVersion: { minor: 43 }, + }) + const view = cleanedConfig.mapViews[0] + expect(view.eventCoordinateFieldFallback).toBe('cascading') + expect(view.config).toBeUndefined() + }) + + test('stores eventCoordinateFieldFallback in the config blob pre-2.43', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'event', + name: 'Test', + opacity: 1, + eventCoordinateFieldFallback: 'cascading', + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + serverVersion: { minor: 42 }, + }) + const view = cleanedConfig.mapViews[0] + expect(JSON.parse(view.config).eventCoordinateFieldFallback).toBe( + 'cascading' + ) + expect(view).not.toHaveProperty('eventCoordinateFieldFallback') + }) + test('serializes unclassifiedLegend into config JSON and removes it from the layer', () => { const cleanedConfig = cleanMapConfig({ config: { diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index 6fab459dda..92baddde00 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -620,7 +620,7 @@ describe('styleByDataItem', () => { values: { ougeometry: 'red', abcDataElementUid1: 'blue' }, }, eventCoordinateField: 'abcDataElementUid1', - fallbackCoordinateField: 'ougeometry', + eventCoordinateFieldFallback: 'ougeometry', geometrySourceNames: { ougeometry: 'Organisation unit location', abcDataElementUid1: 'My custom field', @@ -759,7 +759,7 @@ describe('styleByDataItem', () => { }, }, eventCoordinateField: 'customDataElement1', - fallbackCoordinateField: EVENT_COORDINATE_CASCADING, + eventCoordinateFieldFallback: EVENT_COORDINATE_CASCADING, hasTrackedEntityType: true, geometrySourceNames: { customDataElement1: 'My custom field', @@ -772,8 +772,8 @@ describe('styleByDataItem', () => { expect(result.legend.items.map((i) => i.sourceId)).toEqual([ 'customDataElement1', - 'pigeometry', 'psigeometry', + 'pigeometry', 'teigeometry', 'ougeometry', ]) diff --git a/src/util/__tests__/versionToggle.spec.js b/src/util/__tests__/versionToggle.spec.js index 670d39d09c..fb76a73320 100644 --- a/src/util/__tests__/versionToggle.spec.js +++ b/src/util/__tests__/versionToggle.spec.js @@ -1,6 +1,7 @@ import { serverSupportsGeometrySource, serverSupportsOrgUnitCoordinateField, + serverSupportsEventCoordinateFieldFallback, } from '../versionToggle.js' describe('serverSupportsGeometrySource', () => { @@ -59,3 +60,29 @@ describe('serverSupportsOrgUnitCoordinateField', () => { expect(serverSupportsOrgUnitCoordinateField(undefined)).toBe(false) }) }) + +describe('serverSupportsEventCoordinateFieldFallback', () => { + it('returns false below 2.43', () => { + expect(serverSupportsEventCoordinateFieldFallback({ minor: 42 })).toBe( + false + ) + }) + + it('returns true at 2.43', () => { + expect(serverSupportsEventCoordinateFieldFallback({ minor: 43 })).toBe( + true + ) + }) + + it('returns true above 2.43', () => { + expect(serverSupportsEventCoordinateFieldFallback({ minor: 44 })).toBe( + true + ) + }) + + it('returns false when serverVersion is undefined', () => { + expect(serverSupportsEventCoordinateFieldFallback(undefined)).toBe( + false + ) + }) +}) diff --git a/src/util/coordinatesName.js b/src/util/coordinatesName.js index 83834683da..b5b40f7307 100644 --- a/src/util/coordinatesName.js +++ b/src/util/coordinatesName.js @@ -81,16 +81,16 @@ export const resolveGeometrySourceName = (id, geometrySourceNames) => export const getDefaultGeometrySourceColor = ( id, - { eventCoordinateField, fallbackCoordinateField } + { eventCoordinateField, eventCoordinateFieldFallback } ) => { if (GEOMETRY_SOURCE_COLORS[id]) { return GEOMETRY_SOURCE_COLORS[id] } if (id === eventCoordinateField) { - return qualitativeColors[4] + return qualitativeColors[11] } - if (id === fallbackCoordinateField) { - return qualitativeColors[5] + if (id === eventCoordinateFieldFallback) { + return qualitativeColors[9] } return qualitativeColors[0] } @@ -99,8 +99,8 @@ const expandField = (fieldId, hasTei) => { if (fieldId === EVENT_COORDINATE_CASCADING) { return hasTei ? [ - EVENT_COORDINATE_ENROLLMENT, EVENT_COORDINATE_DEFAULT, + EVENT_COORDINATE_ENROLLMENT, EVENT_COORDINATE_TRACKED_ENTITY, EVENT_COORDINATE_ORG_UNIT, ] @@ -119,7 +119,7 @@ export const loadHasTrackedEntityType = async ({ program, engine }) => { export const getPossibleGeometrySources = ( eventCoordinateField, - fallbackCoordinateField, + eventCoordinateFieldFallback, hasTei ) => { const main = expandField( @@ -127,8 +127,8 @@ export const getPossibleGeometrySources = ( hasTei ) const fallback = - fallbackCoordinateField && fallbackCoordinateField !== NONE - ? expandField(fallbackCoordinateField, hasTei) + eventCoordinateFieldFallback && eventCoordinateFieldFallback !== NONE + ? expandField(eventCoordinateFieldFallback, hasTei) : [] return [...new Set([...main, ...fallback])] } diff --git a/src/util/event.js b/src/util/event.js index af93fbf563..c96c573ca7 100644 --- a/src/util/event.js +++ b/src/util/event.js @@ -84,7 +84,7 @@ export const getAnalyticsRequest = async ( labelDataItem, eventStatus, eventCoordinateField = EVENT_COORDINATE_DEFAULT, - fallbackCoordinateField, + eventCoordinateFieldFallback, relativePeriodDate, isExtended, countFeaturesWithoutCoordinates, @@ -167,14 +167,14 @@ export const getAnalyticsRequest = async ( analyticsRequest = analyticsRequest.withCoordinateField(eventCoordinateField) - if (fallbackCoordinateField) { - if (fallbackCoordinateField === EVENT_COORDINATE_CASCADING) { + if (eventCoordinateFieldFallback) { + if (eventCoordinateFieldFallback === EVENT_COORDINATE_CASCADING) { analyticsRequest = analyticsRequest.withParameters({ defaultCoordinateFallback: true, }) } else { analyticsRequest = analyticsRequest.withParameters({ - fallbackCoordinateField, + eventCoordinateFieldFallback, }) } } diff --git a/src/util/favorites.js b/src/util/favorites.js index 7d6e8e14c2..9751ef8f85 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -8,6 +8,7 @@ import { THEMATIC_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' +import { serverSupportsEventCoordinateFieldFallback } from './versionToggle.js' // TODO: get latitude, longitude, zoom from map + basemap: 'none' const validMapProperties = [ @@ -41,7 +42,7 @@ const validLayerProperties = [ 'displayName', 'endDate', 'eventCoordinateField', - 'fallbackCoordinateField', + 'eventCoordinateFieldFallback', 'eventClustering', 'eventPointColor', 'eventPointRadius', @@ -120,7 +121,7 @@ export const cleanMapConfig = ({ ...omitBy(isNil, pick(validMapProperties, config)), ...getBasemapPayload(config.basemap, defaultBasemapId, serverVersion), mapViews: config.mapViews.map((view) => - cleanLayerConfig(view, cleanMapviewConfig) + cleanLayerConfig(view, cleanMapviewConfig, serverVersion) ), }) @@ -148,17 +149,18 @@ const getBasemapPayload = (basemap, defaultBasemapId, serverVersion) => { } } -const cleanLayerConfig = (layer, cleanMapviewConfig) => ({ +const cleanLayerConfig = (layer, cleanMapviewConfig, serverVersion) => ({ ...models2objects( pick(validLayerProperties, { ...layer, hidden: layer.isVisible === false, }), - cleanMapviewConfig + cleanMapviewConfig, + serverVersion ), }) -const buildCommonLayerConfigData = (layer) => { +const buildCommonLayerConfigData = (layer, serverVersion) => { const configData = {} if (layer.legendDecimalPlaces !== undefined) { configData.legendDecimalPlaces = layer.legendDecimalPlaces @@ -181,10 +183,20 @@ const buildCommonLayerConfigData = (layer) => { if (layer.labelDataItem) { configData.labelDataItem = layer.labelDataItem } + // VERSION-TOGGLE: eventCoordinateFieldFallback isn't a schema field + // pre-2.43 - see util/versionToggle.js. Store it in the config blob + // instead so it still round-trips on older servers. + if ( + layer.eventCoordinateFieldFallback && + !serverSupportsEventCoordinateFieldFallback(serverVersion) + ) { + configData.eventCoordinateFieldFallback = + layer.eventCoordinateFieldFallback + } return configData } -const deleteCommonLayerConfigProps = (layer) => { +const deleteCommonLayerConfigProps = (layer, serverVersion) => { if (layer.noDataLegend) { layer.noDataColor = layer.noDataLegend.color // noDataColor is the DHIS2 API schema field — store color there for backward compatibility } @@ -195,6 +207,9 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countFeaturesWithoutCoordinates delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem + if (!serverSupportsEventCoordinateFieldFallback(serverVersion)) { + delete layer.eventCoordinateFieldFallback + } } const buildEarthEngineLayerConfigData = (layer) => { @@ -237,7 +252,7 @@ const deleteTrackedEntityLayerProps = (layer) => { } // TODO: This feels hacky, find better way to clean map configs before saving -const models2objects = (layer, cleanMapviewConfig) => { +const models2objects = (layer, cleanMapviewConfig, serverVersion) => { const { layer: layerType } = layer Object.keys(layer).forEach((key) => { @@ -279,12 +294,12 @@ const models2objects = (layer, cleanMapviewConfig) => { layerType === FACILITY_LAYER ) { if (cleanMapviewConfig) { - const configData = buildCommonLayerConfigData(layer) + const configData = buildCommonLayerConfigData(layer, serverVersion) if (Object.keys(configData).length) { layer.config = JSON.stringify(configData) } } - deleteCommonLayerConfigProps(layer) + deleteCommonLayerConfigProps(layer, serverVersion) } delete layer.id diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 6f866540c9..e51c6454df 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -98,19 +98,19 @@ const styleByGeometrySource = async (config) => { legend, eventPointRadius, eventCoordinateField, - fallbackCoordinateField, + eventCoordinateFieldFallback, hasTrackedEntityType, } = config const { values } = styleDataItem const names = config.geometrySourceNames || COORDINATE_FIELD_NAMES const possibleSources = getPossibleGeometrySources( eventCoordinateField, - fallbackCoordinateField, + eventCoordinateFieldFallback, hasTrackedEntityType ) // Ordered main field first, then fallback (cascading order if applicable). - legend.unit = i18n.t('Geometry source') + legend.unit = i18n.t('Coordinate source') legend.items = possibleSources .filter((sourceId) => values?.[sourceId]) .map((sourceId) => ({ @@ -136,7 +136,7 @@ const styleByGeometrySource = async (config) => { itemBySource[geometrySource] = { color: getDefaultGeometrySourceColor(geometrySource, { eventCoordinateField, - fallbackCoordinateField, + eventCoordinateFieldFallback, }), radius: eventPointRadius || EVENT_RADIUS, count: 0, diff --git a/src/util/versionToggle.js b/src/util/versionToggle.js index 5b0c5f4fc4..30f1fb0bb9 100644 --- a/src/util/versionToggle.js +++ b/src/util/versionToggle.js @@ -1,6 +1,6 @@ -// VERSION-TOGGLE: pre-2.44, a fallbackCoordinateField pointed at a custom -// ORGANISATION_UNIT field crashes analytics (E7145) and geometrySource is -// never returned. 2.44+ fixes E7145 and returns geometrySource whenever a +// VERSION-TOGGLE: pre-2.44, an eventCoordinateFieldFallback pointed at a +// custom ORGANISATION_UNIT field crashes analytics (E7145) and geometrySource +// is never returned. 2.44+ fixes E7145 and returns geometrySource whenever a // fallback is configured. export const serverSupportsGeometrySource = (serverVersion) => serverVersion?.minor >= 44 @@ -12,3 +12,9 @@ export const serverSupportsOrgUnitCoordinateField = (serverVersion) => (serverVersion?.minor === 40 && serverVersion.patch >= 8) || (serverVersion?.minor === 41 && serverVersion.patch >= 4) || serverVersion?.minor >= 42 + +// VERSION-TOGGLE: mapView.eventCoordinateFieldFallback isn't a schema field +// pre-2.43, so it can't be saved as a native property - store it in the +// config JSON blob instead below that version. +export const serverSupportsEventCoordinateFieldFallback = (serverVersion) => + serverVersion?.minor >= 43 From 7b26c10b99f8dc667c7475cbae16088fee7532a1 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 16:42:02 +0200 Subject: [PATCH 23/26] fix: restore fallbackCoordinateField query param and drop dead no-data toggle --- i18n/en.pot | 10 ++++---- src/components/dataItem/DataItemStyle.jsx | 9 -------- src/util/__tests__/event.spec.js | 28 +++++++++++++++++++++++ src/util/event.js | 2 +- 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index fc4dc7bab3..f7d0209d82 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-21T14:14:37.990Z\n" -"PO-Revision-Date: 2026-09-21T14:14:37.990Z\n" +"POT-Creation-Date: 2026-09-21T14:42:04.085Z\n" +"PO-Revision-Date: 2026-09-21T14:42:04.085Z\n" msgid "2020" msgstr "2020" @@ -119,12 +119,12 @@ msgstr "Fallback coordinate field" msgid "Coordinate field" msgstr "Coordinate field" -msgid "Include events with no data" -msgstr "Include events with no data" - msgid "Include unclassified events" msgstr "Include unclassified events" +msgid "Include events with no data" +msgstr "Include events with no data" + msgid "Coordinate source" msgstr "Coordinate source" diff --git a/src/components/dataItem/DataItemStyle.jsx b/src/components/dataItem/DataItemStyle.jsx index 4abb5c93d0..59039702a0 100644 --- a/src/components/dataItem/DataItemStyle.jsx +++ b/src/components/dataItem/DataItemStyle.jsx @@ -34,15 +34,6 @@ const DataItemStyle = ({ dataItem, style }) => { return (
-
-
- dispatch(setNoDataLegend(v))} - /> -
-
) } diff --git a/src/util/__tests__/event.spec.js b/src/util/__tests__/event.spec.js index c2e0a8878d..d831aeb748 100644 --- a/src/util/__tests__/event.spec.js +++ b/src/util/__tests__/event.spec.js @@ -94,6 +94,34 @@ describe('getAnalyticsRequest', () => { expect(getDimensionCalls('fakeDataElId')[0][1]).toBe('LT:80') }) + it('sends a specific fallback field as fallbackCoordinateField', async () => { + const layer = { + ...baseLayer, + eventCoordinateField: 'psigeometry', + eventCoordinateFieldFallback: 'pigeometry', + } + + await getAnalyticsRequest(layer, baseContext) + + expect(mockRequestInstance.withParameters).toHaveBeenCalledWith({ + fallbackCoordinateField: 'pigeometry', + }) + }) + + it('sends defaultCoordinateFallback for a cascading fallback', async () => { + const layer = { + ...baseLayer, + eventCoordinateField: 'psigeometry', + eventCoordinateFieldFallback: 'cascading', + } + + await getAnalyticsRequest(layer, baseContext) + + expect(mockRequestInstance.withParameters).toHaveBeenCalledWith({ + defaultCoordinateFallback: true, + }) + }) + it('falls back to start/end dates when no period is selected', async () => { await getAnalyticsRequest(baseLayer, baseContext) diff --git a/src/util/event.js b/src/util/event.js index c96c573ca7..ca773ea4b8 100644 --- a/src/util/event.js +++ b/src/util/event.js @@ -174,7 +174,7 @@ export const getAnalyticsRequest = async ( }) } else { analyticsRequest = analyticsRequest.withParameters({ - eventCoordinateFieldFallback, + fallbackCoordinateField: eventCoordinateFieldFallback, }) } } From 09d849f28b3c230941305f29fa43ea74d2465a72 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 16:47:22 +0200 Subject: [PATCH 24/26] chore: sonarqube issues --- src/util/favorites.js | 82 +++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 35 deletions(-) diff --git a/src/util/favorites.js b/src/util/favorites.js index 9751ef8f85..ef4cb57b2a 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -251,6 +251,50 @@ const deleteTrackedEntityLayerProps = (layer) => { delete layer.periodType } +const applyEarthEngineLayerConfig = (layer, cleanMapviewConfig) => { + if (cleanMapviewConfig) { + layer.config = JSON.stringify(buildEarthEngineLayerConfigData(layer)) + } + deleteEarthEngineLayerProps(layer) +} + +const applyTrackedEntityLayerConfig = (layer, cleanMapviewConfig) => { + if (cleanMapviewConfig) { + layer.config = JSON.stringify(buildTrackedEntityLayerConfigData(layer)) + } + deleteTrackedEntityLayerProps(layer) +} + +const applyGeoJsonUrlLayerConfig = (layer, cleanMapviewConfig) => { + if (cleanMapviewConfig) { + layer.config = { + ...layer.config, + featureStyle: { ...layer.featureStyle }, + } + } + delete layer.featureStyle +} + +const applyCommonLayerConfig = (layer, cleanMapviewConfig, serverVersion) => { + if (cleanMapviewConfig) { + const configData = buildCommonLayerConfigData(layer, serverVersion) + if (Object.keys(configData).length) { + layer.config = JSON.stringify(configData) + } + } + deleteCommonLayerConfigProps(layer, serverVersion) +} + +const applyLayerTypeConfigByType = { + [EARTH_ENGINE_LAYER]: applyEarthEngineLayerConfig, + [TRACKED_ENTITY_LAYER]: applyTrackedEntityLayerConfig, + [GEOJSON_URL_LAYER]: applyGeoJsonUrlLayerConfig, + [EVENT_LAYER]: applyCommonLayerConfig, + [THEMATIC_LAYER]: applyCommonLayerConfig, + [ORG_UNIT_LAYER]: applyCommonLayerConfig, + [FACILITY_LAYER]: applyCommonLayerConfig, +} + // TODO: This feels hacky, find better way to clean map configs before saving const models2objects = (layer, cleanMapviewConfig, serverVersion) => { const { layer: layerType } = layer @@ -265,41 +309,9 @@ const models2objects = (layer, cleanMapviewConfig, serverVersion) => { layer.rows = layer.rows.map(cleanDimension) } - if (layerType === EARTH_ENGINE_LAYER) { - if (cleanMapviewConfig) { - layer.config = JSON.stringify( - buildEarthEngineLayerConfigData(layer) - ) - } - deleteEarthEngineLayerProps(layer) - } else if (layerType === TRACKED_ENTITY_LAYER) { - if (cleanMapviewConfig) { - layer.config = JSON.stringify( - buildTrackedEntityLayerConfigData(layer) - ) - } - deleteTrackedEntityLayerProps(layer) - } else if (layerType === GEOJSON_URL_LAYER) { - if (cleanMapviewConfig) { - layer.config = { - ...layer.config, - featureStyle: { ...layer.featureStyle }, - } - } - delete layer.featureStyle - } else if ( - layerType === EVENT_LAYER || - layerType === THEMATIC_LAYER || - layerType === ORG_UNIT_LAYER || - layerType === FACILITY_LAYER - ) { - if (cleanMapviewConfig) { - const configData = buildCommonLayerConfigData(layer, serverVersion) - if (Object.keys(configData).length) { - layer.config = JSON.stringify(configData) - } - } - deleteCommonLayerConfigProps(layer, serverVersion) + const applyLayerTypeConfig = applyLayerTypeConfigByType[layerType] + if (applyLayerTypeConfig) { + applyLayerTypeConfig(layer, cleanMapviewConfig, serverVersion) } delete layer.id From 0bf181d689865a85aa7a63af0439f0a540d4c32e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 17:29:27 +0200 Subject: [PATCH 25/26] fix: address findings in coordinate source styling and fallback fields --- src/components/dataItem/CoordinateField.jsx | 17 +++++----- .../__tests__/CoordinateField.spec.jsx | 20 ++++++++++++ .../edit/shared/GeometryCentroid.jsx | 32 ++++++++++++++++--- .../__tests__/GeometryCentroid.spec.jsx | 32 +++++++++++++++++++ src/components/map/layers/EventLayer.jsx | 1 + src/components/map/layers/EventPopup.jsx | 29 ++++++++++------- src/reducers/__tests__/layerEdit.spec.js | 30 ++++++++++++++++- src/reducers/layerEdit.js | 1 + src/util/coordinatesName.js | 3 +- 9 files changed, 137 insertions(+), 28 deletions(-) create mode 100644 src/components/edit/shared/__tests__/GeometryCentroid.spec.jsx diff --git a/src/components/dataItem/CoordinateField.jsx b/src/components/dataItem/CoordinateField.jsx index df551ac5e0..8a8a51e604 100644 --- a/src/components/dataItem/CoordinateField.jsx +++ b/src/components/dataItem/CoordinateField.jsx @@ -76,6 +76,11 @@ const CoordinateField = ({ }) => { const { serverVersion } = useConfig() const isFallback = !!eventCoordinateField + // true sentinel means the main field is at its implicit default + const mainFieldId = + eventCoordinateField === true + ? EVENT_COORDINATE_DEFAULT + : eventCoordinateField const includeTypes = getIncludeTypes(isFallback, serverVersion) const { @@ -112,10 +117,6 @@ const CoordinateField = ({ name: i18n.t('Event location'), }) - if (!trackedEntityType) { - return fields - } - if (trackedEntityType?.id) { fields.push({ id: EVENT_COORDINATE_ENROLLMENT, @@ -127,14 +128,12 @@ const CoordinateField = ({ }) } - if (eventDataItems) { + if (trackedEntityType && eventDataItems) { fields.push(...eventDataItems) } - return isFallback - ? fields.filter((f) => f.id !== eventCoordinateField) - : fields - }, [trackedEntityType, eventDataItems, eventCoordinateField, isFallback]) + return isFallback ? fields.filter((f) => f.id !== mainFieldId) : fields + }, [trackedEntityType, eventDataItems, mainFieldId, isFallback]) const helpText = getHelpText({ program, diff --git a/src/components/dataItem/__tests__/CoordinateField.spec.jsx b/src/components/dataItem/__tests__/CoordinateField.spec.jsx index 04ca6cb3d5..4ec927b3f4 100644 --- a/src/components/dataItem/__tests__/CoordinateField.spec.jsx +++ b/src/components/dataItem/__tests__/CoordinateField.spec.jsx @@ -74,6 +74,26 @@ describe('CoordinateField', () => { }) }) + it('excludes "Event location" from the fallback options when the main field is left at its implicit default', () => { + mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) + + render( + + ) + + fireEvent.click(screen.getByTestId('dhis2-uicore-select-input')) + + const labels = screen + .getAllByTestId('dhis2-uicore-singleselectoption') + .map((el) => el.textContent) + expect(labels).not.toContain('Event location') + }) + it('renders the OU-type field as a selectable option when included', async () => { mockUseConfig.mockReturnValue({ serverVersion: { minor: 44 } }) // Custom items only appear once a tracked entity type exists. diff --git a/src/components/edit/shared/GeometryCentroid.jsx b/src/components/edit/shared/GeometryCentroid.jsx index 98f179d7c4..3143ec6413 100644 --- a/src/components/edit/shared/GeometryCentroid.jsx +++ b/src/components/edit/shared/GeometryCentroid.jsx @@ -9,20 +9,39 @@ import { EVENT_CENTROID_DEFAULT } from '../../../constants/layers.js' import Checkbox from '../../core/Checkbox.jsx' import styles from './styles/GeometryCentroid.module.css' +// Checks both fields since either one's geometry can end up being plotted +export const needsGeometryCentroid = ( + eventCoordinateFieldType, + eventCoordinateFieldFallbackType +) => + (!!eventCoordinateFieldType && + !EVENT_CENTROID_DEFAULT.includes(eventCoordinateFieldType)) || + (!!eventCoordinateFieldFallbackType && + !EVENT_CENTROID_DEFAULT.includes(eventCoordinateFieldFallbackType)) + const GeometryCentroid = ({ tab, geometryCentroid, eventCoordinateFieldType, + eventCoordinateFieldFallbackType, className, setGeometryCentroid, }) => { useEffect(() => { - if (eventCoordinateFieldType) { - setGeometryCentroid( - !EVENT_CENTROID_DEFAULT.includes(eventCoordinateFieldType) - ) + if (!eventCoordinateFieldType && !eventCoordinateFieldFallbackType) { + return } - }, [setGeometryCentroid, eventCoordinateFieldType]) + setGeometryCentroid( + needsGeometryCentroid( + eventCoordinateFieldType, + eventCoordinateFieldFallbackType + ) + ) + }, [ + setGeometryCentroid, + eventCoordinateFieldType, + eventCoordinateFieldFallbackType, + ]) return (
@@ -66,6 +85,7 @@ const GeometryCentroid = ({ GeometryCentroid.propTypes = { setGeometryCentroid: PropTypes.func.isRequired, className: PropTypes.string, + eventCoordinateFieldFallbackType: PropTypes.string, eventCoordinateFieldType: PropTypes.string, geometryCentroid: PropTypes.bool, tab: PropTypes.string, @@ -75,6 +95,8 @@ export default connect( ({ layerEdit }) => ({ geometryCentroid: layerEdit?.geometryCentroid, eventCoordinateFieldType: layerEdit?.eventCoordinateFieldType, + eventCoordinateFieldFallbackType: + layerEdit?.eventCoordinateFieldFallbackType, }), { setGeometryCentroid } )(GeometryCentroid) diff --git a/src/components/edit/shared/__tests__/GeometryCentroid.spec.jsx b/src/components/edit/shared/__tests__/GeometryCentroid.spec.jsx new file mode 100644 index 0000000000..8cdbcf5ba4 --- /dev/null +++ b/src/components/edit/shared/__tests__/GeometryCentroid.spec.jsx @@ -0,0 +1,32 @@ +import { needsGeometryCentroid } from '../GeometryCentroid.jsx' + +describe('needsGeometryCentroid', () => { + it('is false when both fields are built-in point-only fields', () => { + expect(needsGeometryCentroid('psigeometry', 'pigeometry')).toBe(false) + }) + + it('is false when the main field is a COORDINATE-valueType custom field', () => { + expect(needsGeometryCentroid('COORDINATE', undefined)).toBe(false) + }) + + it('is true when the main field is an org unit field', () => { + expect(needsGeometryCentroid('ougeometry', undefined)).toBe(true) + }) + + it('is true when only the fallback field needs it, main field is default', () => { + expect(needsGeometryCentroid('psigeometry', 'ougeometry')).toBe(true) + }) + + it('is true when the fallback is Cascading, since it always includes org unit', () => { + expect(needsGeometryCentroid('psigeometry', 'cascading')).toBe(true) + }) + + it('is false when the fallback is cleared (undefined type)', () => { + expect(needsGeometryCentroid('psigeometry', undefined)).toBe(false) + }) + + it('is true when either field is an unrecognized custom valueType', () => { + expect(needsGeometryCentroid('TEXT', undefined)).toBe(true) + expect(needsGeometryCentroid('psigeometry', 'TEXT')).toBe(true) + }) +}) diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index 525fc1a2b0..0812439859 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -378,6 +378,7 @@ class EventLayer extends Layer { if ( styleDataItem && + styleDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE && !displayItems.some((item) => item.id === styleDataItem.id) ) { const programResponse = await engine.query( diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index ebaac6140f..ac036a51a9 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -124,19 +124,24 @@ const EventPopup = ({ const { type, coordinates: coord } = feature.geometry const { dataValues = [], occurredAt } = dataEvent?.events || {} - const dataValueIndex = dataValues.findIndex( - (d) => d.dataElement === styleDataItem?.id - ) - if (dataValueIndex !== -1) { - dataValues[dataValueIndex] = { - dataElement: styleDataItem?.id, - value: feature.properties.value, + if ( + styleDataItem && + styleDataItem.id !== EVENT_COORDINATE_GEOMETRY_SOURCE + ) { + const dataValueIndex = dataValues.findIndex( + (d) => d.dataElement === styleDataItem.id + ) + if (dataValueIndex !== -1) { + dataValues[dataValueIndex] = { + dataElement: styleDataItem.id, + value: feature.properties.value, + } + } else { + dataValues.push({ + dataElement: styleDataItem.id, + value: feature.properties.value, + }) } - } else { - dataValues.push({ - dataElement: styleDataItem?.id, - value: feature.properties.value, - }) } const geometrySource = feature.properties[EVENT_COORDINATE_GEOMETRY_SOURCE] diff --git a/src/reducers/__tests__/layerEdit.spec.js b/src/reducers/__tests__/layerEdit.spec.js index d0e4c33d96..67c2b58da6 100644 --- a/src/reducers/__tests__/layerEdit.spec.js +++ b/src/reducers/__tests__/layerEdit.spec.js @@ -76,12 +76,13 @@ describe('layerEdit reducer', () => { expect(result.countFeaturesWithoutCoordinates).toBe(true) }) - it('resets columns/programStage/styleDataItem/labelDataItem when the program changes', () => { + it('resets columns/programStage/styleDataItem/labelDataItem/hasTrackedEntityType when the program changes', () => { const state = { columns: [{ dimension: 'dx' }], programStage: { id: 'stage1' }, styleDataItem: { id: 'sd1' }, labelDataItem: { id: 'ld1' }, + hasTrackedEntityType: true, } const result = layerEdit(state, { @@ -96,9 +97,36 @@ describe('layerEdit reducer', () => { programStage: null, styleDataItem: null, labelDataItem: null, + hasTrackedEntityType: null, }) }) + it("resets hasTrackedEntityType to null pending the new program's TEI query, closing the stale-cascade window", () => { + const afterProgramChange = layerEdit( + { + hasTrackedEntityType: true, + eventCoordinateField: 'psigeometry', + eventCoordinateFieldFallback: 'cascading', + }, + { + type: types.LAYER_EDIT_PROGRAM_SET, + program: { id: 'prog2' }, + } + ) + + expect(afterProgramChange.hasTrackedEntityType).toBe(null) + + const afterStyleSet = layerEdit(afterProgramChange, { + type: types.LAYER_EDIT_STYLE_DATA_ITEM_SET, + dataItem: { id: EVENT_COORDINATE_GEOMETRY_SOURCE }, + }) + + expect(Object.keys(afterStyleSet.styleDataItem.values)).toEqual([ + 'psigeometry', + 'ougeometry', + ]) + }) + it('sets program to null when the program is cleared', () => { const result = layerEdit( {}, diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index b3933c0bbd..4c5531784d 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -73,6 +73,7 @@ const layerEdit = (state = null, action) => { programStage: null, styleDataItem: null, labelDataItem: null, + hasTrackedEntityType: null, } case types.LAYER_EDIT_PROGRAM_STAGE_SET: diff --git a/src/util/coordinatesName.js b/src/util/coordinatesName.js index b5b40f7307..04b40add27 100644 --- a/src/util/coordinatesName.js +++ b/src/util/coordinatesName.js @@ -92,7 +92,8 @@ export const getDefaultGeometrySourceColor = ( if (id === eventCoordinateFieldFallback) { return qualitativeColors[9] } - return qualitativeColors[0] + // Unrecognized source id - last unused palette slot, avoids collisions + return qualitativeColors[8] } const expandField = (fieldId, hasTei) => { From 97c0517af99167e4e873ebde4d39d83de2cf6011 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 21 Sep 2026 18:07:20 +0200 Subject: [PATCH 26/26] chore: add exhaustive event layer coordinate/fallback local cypress test --- .../eventCoordinateFallbackScenarios.cy.js | 623 ++++++++++++++++++ 1 file changed, 623 insertions(+) create mode 100644 cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js diff --git a/cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js b/cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js new file mode 100644 index 0000000000..2459998c75 --- /dev/null +++ b/cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js @@ -0,0 +1,623 @@ +// Exhaustive check of Event layer coordinateField/fallbackCoordinateField +// resolution, using test data from the test-data-event-layer-coordinates +// tool in dhis2/maps-tools. +// +// Prerequisites: run that tool's index.js and runAnalytics.js against the +// target instance, with --fixturesRepo= (see its +// README) - this writes cypress/fixtures/eventCoordinateFallbackScenarios.json +// and eventCoordinateFallbackFieldIds.json, which this spec reads directly (a +// missing/stale fixture fails cy.fixture() with a clear error). Also point +// cypress.env.json / the app dev server at the same instance. +// +// For each program (event-only and tracker), cycles through every main +// coordinate field crossed with every fallback option CoordinateField.jsx +// offers for it. Each combo is checked two ways against an expectation +// computed here from the fixtures (same resolution rule - main field +// first, then fallback - verify.js uses): the legend's "events without +// coordinates" count, and - more strongly - the data table's exact set of +// scenario codes with no Point geometry. The count alone could pass by +// coincidence if the wrong events resolved; the exact-set check can't. +// +// Slow (30-60+ min), so it's marked "include": false in +// cypress/support/cypressFiles.json. Run directly: +// yarn cypress run --spec cypress/integration/layers/eventCoordinateFallbackScenarios.cy.js + +import { EventLayer } from '../../elements/event_layer.js' +import { EXTENDED_TIMEOUT } from '../../support/util.js' + +Cypress.on('uncaught:exception', (err) => { + if ( + err.message.includes( + 'ResizeObserver loop completed with undelivered notifications.' + ) + ) { + return false + } +}) + +const LABELS = { + EVENT_LOCATION: 'Event location', + ORG_UNIT_LOCATION: 'Organisation unit location', + ENROLLMENT_LOCATION: 'Enrollment location', + TRACKED_ENTITY_LOCATION: 'Tracked entity location', + CUSTOM_COORDINATE_DE: 'Maps Test Coordinate DE', + CUSTOM_ORGUNIT_DE: 'Maps Test OrgUnit DE', + CUSTOM_COORDINATE_TEA: 'Maps Test Coordinate TEA', + CUSTOM_ORGUNIT_TEA: 'Maps Test OrgUnit TEA', + CASCADING: 'Cascading', +} +const NONE_LABEL = 'None' +const SCENARIO_CODE_COLUMN = 'Maps Test Scenario Code DE' + +// The default org unit selection and relative period don't cover the test +// data's org units/dates, so both need to be set explicitly. The wide fixed +// range mirrors verify.js's own START_DATE/END_DATE, so it keeps working +// regardless of which day the data was imported. +const TEST_ORG_UNITS = [ + 'Maps Test OU (with geometry)', + 'Maps Test OU (no geometry)', +] +const PERIOD_START_DATE = '2020-01-01' +const PERIOD_END_DATE = '2030-12-31' + +const EVENT_PROGRAM_MAIN_FIELDS = [ + 'EVENT_LOCATION', + 'ORG_UNIT_LOCATION', + 'CUSTOM_COORDINATE_DE', + 'CUSTOM_ORGUNIT_DE', +] +const TRACKER_PROGRAM_MAIN_FIELDS = [ + 'EVENT_LOCATION', + 'ORG_UNIT_LOCATION', + 'ENROLLMENT_LOCATION', + 'TRACKED_ENTITY_LOCATION', + 'CUSTOM_COORDINATE_DE', + 'CUSTOM_ORGUNIT_DE', + 'CUSTOM_COORDINATE_TEA', + 'CUSTOM_ORGUNIT_TEA', +] + +// Custom ORGANISATION_UNIT-type fields need 2.44+ as a fallback (see +// src/util/versionToggle.js). Duplicated here rather than imported, since +// app source can't be required into this browser-run spec. +// +// A separate, unrelated gate (CoordinateField.jsx) controls whether such a +// field appears as a MAIN field at all: 2.40.8+, 2.41.4+, or 2.42+. Not +// modeled here - assumed satisfied by whatever instance this runs against. +// If it isn't, selectCoordinate()/selectPopperOption() fail loudly (option +// not found) rather than passing incorrectly. +const ORG_UNIT_TYPE_FIELDS = ['CUSTOM_ORGUNIT_DE', 'CUSTOM_ORGUNIT_TEA'] + +const getServerMinorVersion = () => { + const v = String(Cypress.env('dhis2InstanceVersion') || '') + if (v.toLowerCase() === 'dev') { + return Infinity + } + const match = v.match(/(\d+)\.(\d+)/) + return match ? parseInt(match[2], 10) : parseInt(v, 10) +} + +const supportsOrgUnitFallback = getServerMinorVersion() >= 44 + +// Builds every {mainField, fallbackField} combo the app's CoordinateField.jsx +// would offer for this field set: for each main field, fallback options are +// none, cascading, and every other field (minus org-unit-type fields when +// unsupported) - mirrors verify.js's buildFullTestCases(). +const buildCombos = (mainFields) => + mainFields.flatMap((mainField) => { + const otherFields = mainFields.filter( + (field) => + field !== mainField && + (supportsOrgUnitFallback || + !ORG_UNIT_TYPE_FIELDS.includes(field)) + ) + return [null, 'CASCADING', ...otherFields].map((fallbackField) => ({ + mainField, + fallbackField, + })) + }) + +const labelOf = (fieldKey) => (fieldKey ? LABELS[fieldKey] : NONE_LABEL) + +const comboTitle = (combo) => + `main=${labelOf(combo.mainField)} fallback=${labelOf(combo.fallbackField)}` + +// Loaded once in the outer before() below, from cypress/fixtures/ - see +// maps-tools' test-data-event-layer-coordinates/idMap.js and fieldIds.js, +// which write those fixtures directly (this spec never reads that tool's +// own files). +let scenarios +let fieldIds + +const PROGRAM_NAMES = { + event: 'Maps Test Coordinates (Event)', + tracker: 'Maps Test Coordinates (Tracker)', +} + +const CASCADING_ID = 'cascading' +const CASCADE_WITH_TEI = [ + 'psigeometry', + 'pigeometry', + 'teigeometry', + 'ougeometry', +] +const CASCADE_WITHOUT_TEI = ['psigeometry', 'ougeometry'] + +// Mirrors resolve.js's cascade order and resolution rule (main field first, +// then the fallback) - kept in sync manually since this spec intentionally +// doesn't import that script. +const resolveExpectedField = ({ points, hasTei, mainField, fallbackField }) => { + if (points[mainField]) { + return mainField + } + if (fallbackField === CASCADING_ID) { + const chain = hasTei ? CASCADE_WITH_TEI : CASCADE_WITHOUT_TEI + return chain.find((candidate) => points[candidate]) ?? null + } + if (fallbackField && points[fallbackField]) { + return fallbackField + } + return null +} + +// Maps the spec's semantic field keys to the real field id +// resolveExpectedField() needs: fixed strings for the built-in fields, and +// this run's generated custom DE/TEA ids (from the fieldIds fixture). +const resolveFieldId = (key) => { + if (key === null || key === 'NONE') { + return null + } + const map = { + EVENT_LOCATION: 'psigeometry', + ORG_UNIT_LOCATION: 'ougeometry', + ENROLLMENT_LOCATION: 'pigeometry', + TRACKED_ENTITY_LOCATION: 'teigeometry', + CASCADING: CASCADING_ID, + CUSTOM_COORDINATE_DE: fieldIds.deCoordinate, + CUSTOM_ORGUNIT_DE: fieldIds.deOrgUnit, + CUSTOM_COORDINATE_TEA: fieldIds.teaCoordinate, + CUSTOM_ORGUNIT_TEA: fieldIds.teaOrgUnit, + } + if (!(key in map)) { + throw new Error(`Unknown scenario field key: ${key}`) + } + return map[key] +} + +const getScenarioEvents = (program) => { + const programName = PROGRAM_NAMES[program] + const events = scenarios.filter( + (e) => e.type === 'event' && e.program === programName + ) + if (events.length === 0) { + throw new Error( + `No "${programName}" events found in the scenarios fixture` + ) + } + return events +} + +// Computes, for a program + main/fallback combo, the set of scenario codes +// expected to end up with no geometry (and the program's total event +// count) - the single source of truth both assertions below check against. +const resolveComboExpectation = ({ + program, + hasTei, + mainField, + fallbackField, +}) => { + const events = getScenarioEvents(program) + const mainId = resolveFieldId(mainField) + const fallbackId = resolveFieldId(fallbackField) + + const withoutCoordsCodes = new Set() + events.forEach((event) => { + const field = resolveExpectedField({ + points: event.points, + hasTei, + mainField: mainId, + fallbackField: fallbackId, + }) + if (field === null) { + withoutCoordsCodes.add(event.scenarioCode) + } + }) + + return { total: events.length, withoutCoordsCodes } +} + +// Retries (like cy.get().should(cb)) rather than a one-shot read, since the +// legend text updates asynchronously after the layer reloads. +const assertEventsWithoutCoordinatesCount = (expected) => { + cy.getByDataTest('layerlegend', EXTENDED_TIMEOUT).should(($legend) => { + const text = $legend.text() + let actual + if (/all events have coordinates/i.test(text)) { + actual = 0 + } else { + const match = text.match( + /([\d,. ]+)\s*events? without coordinates/i + ) + expect(match, `"events without coordinates" text in: ${text}`).to + .not.be.null + actual = parseInt(match[1].replace(/\D/g, ''), 10) + } + expect(actual, 'events without coordinates count').to.equal(expected) + }) +} + +const getColumnIndices = ($panel) => { + const headerCells = [ + ...$panel[0].querySelectorAll( + '[data-test="dhis2-uicore-datatablecellhead"]' + ), + ] + const scenarioCodeIndex = headerCells.findIndex((cell) => + cell.textContent + .toLowerCase() + .includes(SCENARIO_CODE_COLUMN.toLowerCase()) + ) + const typeIndex = headerCells.findIndex( + (cell) => cell.textContent.trim().toLowerCase() === 'type' + ) + expect(scenarioCodeIndex, 'scenario code column found').to.be.at.least(0) + expect(typeIndex, 'type column found').to.be.at.least(0) + return { scenarioCodeIndex, typeIndex } +} + +const readRenderedRows = ($panel, { scenarioCodeIndex, typeIndex }) => + [ + ...$panel[0].querySelectorAll( + '[data-test="dhis2-uicore-tablebody"] [data-test="dhis2-uicore-datatablerow"]' + ), + ].map((row) => { + const cells = row.querySelectorAll('td') + return { + scenarioCode: cells[scenarioCodeIndex]?.textContent.trim(), + hasPoint: cells[typeIndex]?.textContent.trim() === 'Point', + } + }) + +// The table body is virtualized (react-virtuoso's TableVirtuoso): only rows +// currently scrolled into view exist in the DOM, so a single querySelectorAll +// only ever sees a handful of rows. Scrolls the panel's +// [data-testid="virtuoso-scroller"] (react-virtuoso's own stable test id) +// from top to bottom in viewport-height steps, collecting every row rendered +// along the way, keyed by scenario code so revisited rows don't double count. +const MAX_SCROLL_STEPS = 300 + +// Reads the scroller and its metrics within a single then() callback - if a +// DOM element is returned across a then() boundary, Cypress re-wraps it as a +// jQuery object for the next callback, and jQuery objects don't expose +// clientHeight/scrollHeight (and .scrollTop is a method, not a number). +// +// Stops once a requested scroll doesn't actually move scrollTop any further, +// rather than comparing against scrollHeight up front - react-virtuoso can +// under-report scrollHeight before it's finished measuring row heights, +// which was ending the walk after a single step. +// +// Steps by half a viewport (not a full one), and after each scroll waits for +// two consecutive reads of the rendered rows to come back identical - +// react-virtuoso mounts newly-visible rows asynchronously (a tick after the +// scroll position itself changes), so reading right after a full-viewport +// jump could land mid-render and skip whatever hadn't mounted yet. +const rowsSignature = (rows) => + rows + .map(({ scenarioCode, hasPoint }) => `${scenarioCode}:${hasPoint}`) + .join('|') + +const waitForRenderedRowsToSettle = () => { + let lastSignature = null + let stableCount = 0 + + return cy + .getByDataTest('bottom-panel', EXTENDED_TIMEOUT) + .should(($panel) => { + const signature = rowsSignature( + readRenderedRows($panel, getColumnIndices($panel)) + ) + if (signature === lastSignature) { + stableCount++ + } else { + stableCount = 0 + lastSignature = signature + } + expect(stableCount, 'rendered rows settled').to.be.at.least(1) + }) +} + +const collectAllTableRows = ( + stepsLeft = MAX_SCROLL_STEPS, + seen = new Map(), + lastScrollTop = -1 +) => + waitForRenderedRowsToSettle().then(($panel) => { + const indices = getColumnIndices($panel) + readRenderedRows($panel, indices).forEach((row) => { + if (row.scenarioCode) { + seen.set(row.scenarioCode, row.hasPoint) + } + }) + + const scroller = $panel[0].querySelector( + '[data-testid="virtuoso-scroller"]' + ) + const scrollTop = scroller.scrollTop + + if (scrollTop === lastScrollTop || stepsLeft <= 0) { + return seen + } + + cy.wrap(scroller).scrollTo(0, scrollTop + scroller.clientHeight / 2, { + ensureScrollable: false, + }) + return cy.then(() => + collectAllTableRows(stepsLeft - 1, seen, scrollTop) + ) + }) + +// Reads the data table (opened by checkCombo() just before this runs) and +// asserts the exact set of scenario codes with no Point geometry matches +// expectation - not just how many, so a bug that resolves the right NUMBER +// of events but the wrong ONES still fails. +const assertTableMatchesExpectation = ({ total, withoutCoordsCodes }) => { + cy.getByDataTest('bottom-panel', EXTENDED_TIMEOUT) + .find('[data-testid="virtuoso-scroller"]') + .scrollTo('top', { ensureScrollable: false }) + + collectAllTableRows().then((seen) => { + expect(seen.size, 'total table rows').to.equal(total) + + const actualWithoutCoordsCodes = [...seen] + .filter(([, hasPoint]) => !hasPoint) + .map(([scenarioCode]) => scenarioCode) + + expect( + actualWithoutCoordsCodes.sort(), + 'scenario codes with no point geometry' + ).to.deep.equal([...withoutCoordsCodes].sort()) + }) +} + +// dhis2-uicore-layer is @dhis2/ui's generic Layer wrapper - used by +// Popover, but also by Modal (the edit dialog itself), which leaves its +// own instance in the DOM hidden (display: none) rather than unmounting it +// when closed. So an open popover has to be told apart from that hidden +// leftover by visibility, not just presence. +const VISIBLE_LAYER_SELECTOR = '[data-test="dhis2-uicore-layer"]:visible' + +// Dismisses an open popover without toggling anything - re-clicking its +// trigger button doesn't work here, since the popover's own layer covers +// the whole viewport, sitting above the trigger while open. Established +// pattern (see thematic_layer.js): click the outer layer wrapper's +// topLeft corner, away from wherever the popover's own content is +// positioned. .last() targets the most recently opened one, in case a +// stray one is left over elsewhere. +const dismissPopover = () => + cy.get(VISIBLE_LAYER_SELECTOR).last().click('topLeft') + +// If a previous combo's attempt failed mid-selection, the edit dialog can +// be left open with a stale, half-applied selection, and the field +// dropdown it was mid-click on can still be open too - layered above the +// modal, covering its Cancel button. Clears that nested popover first (a +// stray dismissPopover() click on the modal's own layer is a harmless +// no-op, since Modals don't close on an outside/backdrop click by design), +// then uses the modal's own Cancel button for a clean, predictable +// starting point rather than trying to resume whatever state it's in. +const ensureEditDialogClosed = () => { + cy.get('body').then(($body) => { + if ($body.find(VISIBLE_LAYER_SELECTOR).length > 0) { + dismissPopover() + } + }) + cy.get('body').then(($body) => { + if ($body.find('[data-test="layeredit"]').length > 0) { + cy.getByDataTest('dhis2-uicore-modalactions') + .contains('Cancel') + .click() + } + }) + cy.getByDataTest('layeredit').should('not.exist') +} + +// EventDataItemsProvider.jsx wraps the whole edit dialog and fires these +// two queries as soon as it mounts (see useProgramStageDataElements.js and +// useProgramTrackedEntityAttributes.js) - CoordinateField.jsx's option +// list depends on both, and doesn't stop changing until they resolve. +// Waiting on them directly (rather than polling the DOM for "it looks +// stable now") closes the race at its source instead of narrowing it. +// intercept matches against the raw request URL, so query values need +// encoding to match too - established pattern, see manageLayerSources.cy.js +// and usersettings.cy.js. +const interceptCoordinateFieldDataItems = () => { + cy.intercept( + 'GET', + `**/api/*/programStages/*?fields=${encodeURIComponent( + 'programStageDataElements' + )}*` + ).as('programStageDataElements') + cy.intercept( + 'GET', + `**/api/*/programs/*?fields=${encodeURIComponent( + 'trackedEntityType,programTrackedEntityAttributes' + )}*` + ).as('programTrackedEntityAttributes') +} + +const applyCombo = (Layer, combo) => { + ensureEditDialogClosed() + interceptCoordinateFieldDataItems() + cy.getByDataTest('layer-edit-button').click() + cy.wait( + ['@programStageDataElements', '@programTrackedEntityAttributes'], + EXTENDED_TIMEOUT + ) + Layer.selectTab('Data') + Layer.selectCoordinate(labelOf(combo.mainField)) + Layer.selectFallbackCoordinate(labelOf(combo.fallbackField)) + Layer.updateMap() + Layer.validateDialogClosed(true) + cy.waitForMap() +} + +const checkCombo = (program, hasTei, combo) => { + const expectation = resolveComboExpectation({ + program, + hasTei, + mainField: combo.mainField, + fallbackField: combo.fallbackField, + }) + assertEventsWithoutCoordinatesCount(expectation.withoutCoordsCodes.size) + + // Opened/closed around just this check, not left open during applyCombo: + // BottomPanel's ResizeObserver keeps reflowing the page while the table + // is open, which was detaching the edit dialog's field popper mid-click. + openDataTable() + assertTableMatchesExpectation(expectation) + closeDataTable() +} + +// Maximizes the panel (BottomPanel.jsx clamps this to the available window +// height) instead of the default 300px, so far fewer scroll steps are +// needed to see every row - simulating the drag-to-resize handle itself +// isn't practical from Cypress. window.store is only exposed when +// window.Cypress is set (see src/store/index.js). +const maximizeDataTableHeight = () => + cy.window().its('store').invoke('dispatch', { + type: 'DATA_TABLE_RESIZE', + height: 10000, + }) + +// Tolerates the table already being open (e.g. left open by a failed/ +// retried test) instead of assuming "Show data table" is always the menu's +// current label. Then waits out DataTable.jsx's own loading cover +// (dhis2-uicore-componentcover, shown while the event layer's "extended"/ +// full-geometry data is still loading) - the panel and even some rows can +// render before that finishes, which under-counted rows before this wait. +const openDataTable = () => { + cy.getByDataTest('moremenubutton').first().click() + cy.getByDataTest('more-menu').then(($menu) => { + if (/hide data table/i.test($menu.text())) { + dismissPopover() + } else { + cy.wrap($menu).find('li').contains('Show data table').click() + } + }) + cy.getByDataTest('bottom-panel', EXTENDED_TIMEOUT).should('be.visible') + cy.getByDataTest('bottom-panel', EXTENDED_TIMEOUT) + .find('[data-test="dhis2-uicore-componentcover"]') + .should('not.exist') + cy.getByDataTest('dhis2-uicore-datatablerow', EXTENDED_TIMEOUT).should( + 'have.length.greaterThan', + 0 + ) + maximizeDataTableHeight() +} + +const closeDataTable = () => { + cy.getByDataTest('moremenubutton').first().click() + cy.getByDataTest('more-menu').then(($menu) => { + if (/show data table/i.test($menu.text())) { + dismissPopover() + } else { + cy.wrap($menu).find('li').contains('Hide data table').click() + } + }) + cy.getByDataTest('bottom-panel').should('not.exist') +} + +describe('Event layer coordinate/fallback scenario matrix', () => { + before(() => { + cy.fixture('eventCoordinateFallbackScenarios.json').then((data) => { + scenarios = data + }) + cy.fixture('eventCoordinateFallbackFieldIds.json').then((data) => { + fieldIds = data + }) + }) + + // testIsolation: false - the layer added in before() must stay on + // screen across every it() below; default test isolation resets to a + // blank page between tests and would remove it. Only supported as a + // suite/test config override, not via Cypress.config() at runtime. + describe( + 'Maps Test Coordinates (Event) - no registration', + { testIsolation: false }, + () => { + const combos = buildCombos(EVENT_PROGRAM_MAIN_FIELDS) + + before(() => { + cy.visit('/') + + const Layer = new EventLayer() + Layer.openDialog('Events') + .selectProgram('Maps Test Coordinates (Event)') + .validateStage('Maps Test Stage (Event)') + .selectTab('Period') + .selectStartEndDates() + .typeStartDate(PERIOD_START_DATE) + .typeEndDate(PERIOD_END_DATE) + .selectTab('Org Units') + .unselectOu('Sierra Leone') + .selectOu(TEST_ORG_UNITS[0]) + .selectOu(TEST_ORG_UNITS[1]) + .selectTab('Style') + .selectCountEventsWithoutCoordinates() + .selectLabelField('Maps Test Scenario Code DE') + + Layer.addToMap() + Layer.validateDialogClosed(true) + cy.waitForMap() + }) + + combos.forEach((combo) => { + it(comboTitle(combo), () => { + const Layer = new EventLayer() + applyCombo(Layer, combo) + checkCombo('event', false, combo) + }) + }) + } + ) + + // testIsolation: false - see the event program's describe() above. + describe( + 'Maps Test Coordinates (Tracker) - with registration', + { testIsolation: false }, + () => { + const combos = buildCombos(TRACKER_PROGRAM_MAIN_FIELDS) + + before(() => { + cy.visit('/') + + const Layer = new EventLayer() + Layer.openDialog('Events') + .selectProgram('Maps Test Coordinates (Tracker)') + .validateStage('Maps Test Stage (Tracker)') + .selectTab('Period') + .selectStartEndDates() + .typeStartDate(PERIOD_START_DATE) + .typeEndDate(PERIOD_END_DATE) + .selectTab('Org Units') + .unselectOu('Sierra Leone') + .selectOu(TEST_ORG_UNITS[0]) + .selectOu(TEST_ORG_UNITS[1]) + .selectTab('Style') + .selectCountEventsWithoutCoordinates() + .selectLabelField('Maps Test Scenario Code DE') + + Layer.addToMap() + Layer.validateDialogClosed(true) + cy.waitForMap() + }) + + combos.forEach((combo) => { + it(comboTitle(combo), () => { + const Layer = new EventLayer() + applyCombo(Layer, combo) + checkCombo('tracker', true, combo) + }) + }) + } + ) +})