From 13b4a1a25b5406fb620e7f22156012e95278e20a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 10:29:20 +0200 Subject: [PATCH 1/6] feat: format numeric values with digit group separator [DHIS2-18963] --- src/components/datatable/DataTable.jsx | 13 +- src/components/datatable/useTableData.js | 20 +- .../edit/earthEngine/LegendPreview.jsx | 8 +- src/components/legend/Bubble.jsx | 11 +- src/components/legend/LegendItemRange.jsx | 37 ++- src/components/map/layers/EventLayer.jsx | 4 +- src/components/map/layers/EventPopup.jsx | 11 +- src/components/map/layers/GeoJsonLayer.js | 8 +- .../map/layers/TrackedEntityLayer.jsx | 4 +- .../map/layers/TrackedEntityPopup.jsx | 11 +- src/components/orgunits/OrgUnitInfo.jsx | 14 +- src/components/plugin/LayerLoader.jsx | 7 +- src/constants/settings.js | 6 + src/hooks/useLayersLoader.js | 7 +- src/loaders/earthEngineLoader.js | 29 ++- src/loaders/eventLoader.js | 6 +- src/loaders/geoJsonUrlLoader.js | 10 +- src/loaders/thematicLoader.js | 16 +- src/loaders/trackedEntityLoader.js | 216 +++++++++++------- src/util/__tests__/numbers.spec.js | 125 +++++++++- src/util/helpers.js | 13 +- src/util/numbers.js | 41 ++++ 22 files changed, 495 insertions(+), 122 deletions(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 37afc82af..d0090238a 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -26,6 +26,8 @@ import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' import { setOrgUnitProfile } from '../../actions/orgUnits.js' import { EVENT_LAYER, GEOJSON_URL_LAYER } from '../../constants/layers.js' import { isDarkColor } from '../../util/colors.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import FilterInput from './FilterInput.jsx' import styles from './styles/DataTable.module.css' import { useTableData } from './useTableData.js' @@ -88,6 +90,10 @@ const TableComponents = { } const Table = ({ availableHeight, availableWidth }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + const headerRowRef = useRef(null) const [columnWidths, setColumnWidths] = useState([]) const { mapViews } = useSelector((state) => state.map) @@ -293,7 +299,12 @@ const Table = ({ availableHeight, availableWidth }) => { backgroundColor={dataKey === 'color' ? value : null} align={align} > - {dataKey === 'color' ? value?.toLowerCase() : value} + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} )) } diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index d5586e8d3..778d54c7c 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -25,7 +25,7 @@ const TYPE_DATE = 'date' const INDEX = 'index' const NAME = 'name' const ID = 'id' -const VALUE = 'value' +const VALUE = 'rawValue' const LEGEND = 'legend' const RANGE = 'range' const LEVEL = 'level' @@ -298,30 +298,30 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { //sort filteredData.sort((a, b) => { - a = a[sortField] - b = b[sortField] + const aVal = a[sortField] + const bVal = b[sortField] // All undefined values should be sorted to the end - if (a === undefined && b === undefined) { + if (aVal === undefined && bVal === undefined) { return 0 } - if (a === undefined) { + if (aVal === undefined) { return 1 // a goes to end } - if (b === undefined) { + if (bVal === undefined) { return -1 // b goes to end } - if (typeof a === TYPE_NUMBER) { - return sortDirection === ASCENDING ? a - b : b - a + if (typeof aVal === TYPE_NUMBER) { + return sortDirection === ASCENDING ? aVal - bVal : bVal - aVal } // TODO: Make sure sorting works across different locales return sortDirection === ASCENDING - ? a.localeCompare(b) - : b.localeCompare(a) + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal) }) return filteredData.map((item) => diff --git a/src/components/edit/earthEngine/LegendPreview.jsx b/src/components/edit/earthEngine/LegendPreview.jsx index 73f0b3fd9..72a1b1f0e 100644 --- a/src/components/edit/earthEngine/LegendPreview.jsx +++ b/src/components/edit/earthEngine/LegendPreview.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import React from 'react' import { createLegend } from '../../../loaders/earthEngineLoader.js' import { sortLegendItems } from '../../../util/legend.js' +import { useCachedData } from '../../cachedDataProvider/CachedDataProvider.jsx' import LegendItem from '../../legend/LegendItem.jsx' import styles from '../styles/LayerDialog.module.css' @@ -10,7 +11,12 @@ const styleIsValid = ({ min, max }) => !Number.isNaN(min) && !Number.isNaN(max) && max > min const LegendPreview = ({ style, showBelowMin }) => { - const legend = styleIsValid(style) && createLegend(style, showBelowMin) + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + const legend = + styleIsValid(style) && + createLegend(style, showBelowMin, keyAnalysisDigitGroupSeparator) return legend ? (
diff --git a/src/components/legend/Bubble.jsx b/src/components/legend/Bubble.jsx index 5d84adb7f..7db2b0f85 100644 --- a/src/components/legend/Bubble.jsx +++ b/src/components/legend/Bubble.jsx @@ -1,5 +1,7 @@ import PropTypes from 'prop-types' import React from 'react' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { guideLength, textPadding } from './Bubbles.jsx' const Bubble = ({ @@ -11,6 +13,9 @@ const Bubble = ({ stroke, pattern, }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const leftAlign = textAlign === 'left' const x = maxRadius const y = maxRadius * 2 - radius @@ -50,7 +55,11 @@ const Bubble = ({ alignmentBaseline="middle" style={{ fontSize: 12 }} > - {text} + {formatWithSeparator( + text, + keyAnalysisDigitGroupSeparator, + { force: true } + )} )} diff --git a/src/components/legend/LegendItemRange.jsx b/src/components/legend/LegendItemRange.jsx index 4c09d4ddc..28b78ca7e 100644 --- a/src/components/legend/LegendItemRange.jsx +++ b/src/components/legend/LegendItemRange.jsx @@ -1,13 +1,38 @@ import PropTypes from 'prop-types' import React from 'react' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import styles from './styles/LegendItemRange.module.css' -const LegendItemRange = ({ name = '', startValue, endValue, count }) => ( - - {isNaN(startValue) ? name : `${name} ${startValue} - ${endValue}`} - {count !== undefined ? ` (${count})` : ''} - -) +const LegendItemRange = ({ name = '', startValue, endValue, count }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + + const nameLabel = name ? `${name} ` : '' + const rangeLabel = + startValue === undefined || Number.isNaN(startValue) + ? '' + : `${formatWithSeparator( + startValue, + keyAnalysisDigitGroupSeparator + )} - ${formatWithSeparator( + endValue, + keyAnalysisDigitGroupSeparator + )}` + const countLabel = + count === undefined + ? '' + : ` (${formatWithSeparator(count, keyAnalysisDigitGroupSeparator)})` + + return ( + + {nameLabel} + {rangeLabel} + {countLabel} + + ) +} LegendItemRange.propTypes = { count: PropTypes.number, diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index b71cf8a59..fd9103b82 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -152,7 +152,8 @@ class EventLayer extends Layer { } render() { - const { styleDataItem, nameProperty } = this.props + const { styleDataItem, nameProperty, keyAnalysisDigitGroupSeparator } = + this.props const { popup, displayItems, eventCoordinateFieldName } = this.state return popup && displayItems ? ( @@ -160,6 +161,7 @@ class EventLayer extends Layer { {...popup} styleDataItem={styleDataItem} nameProperty={nameProperty} + keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator} displayItems={displayItems} eventCoordinateFieldName={eventCoordinateFieldName} onClose={this.onPopupClose} diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index f40dbd961..a6ffca021 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -19,7 +19,12 @@ const EVENTS_QUERY = { }, } -const getDataRows = ({ displayItems, dataValues, orgUnitNames }) => { +const getDataRows = ({ + displayItems, + dataValues, + orgUnitNames, + keyAnalysisDigitGroupSeparator, +}) => { const dataRows = [] // Include rows for each data item used for styling and displayInReport @@ -30,6 +35,7 @@ const getDataRows = ({ displayItems, dataValues, orgUnitNames }) => { valueType, options, orgUnitNames, + keyAnalysisDigitGroupSeparator, }) dataRows.push( @@ -53,6 +59,7 @@ const EventPopup = ({ feature, styleDataItem, nameProperty, + keyAnalysisDigitGroupSeparator, displayItems, eventCoordinateFieldName, onClose, @@ -151,6 +158,7 @@ const EventPopup = ({ displayItems, dataValues, orgUnitNames, + keyAnalysisDigitGroupSeparator, })} {type === 'Point' && ( @@ -187,6 +195,7 @@ EventPopup.propTypes = { nameProperty: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, eventCoordinateFieldName: PropTypes.string, + keyAnalysisDigitGroupSeparator: PropTypes.string, styleDataItem: PropTypes.object, } diff --git a/src/components/map/layers/GeoJsonLayer.js b/src/components/map/layers/GeoJsonLayer.js index 782302960..321248e1a 100644 --- a/src/components/map/layers/GeoJsonLayer.js +++ b/src/components/map/layers/GeoJsonLayer.js @@ -1,6 +1,7 @@ import { GEOJSON_LAYER } from '../../../constants/layers.js' import { filterData } from '../../../util/filter.js' import { getGeojsonDisplayData } from '../../../util/geojson.js' +import { formatWithSeparator } from '../../../util/numbers.js' import Layer from './Layer.js' class GeoJsonLayer extends Layer { @@ -53,13 +54,18 @@ class GeoJsonLayer extends Layer { } onFeatureClick(evt) { + const { keyAnalysisDigitGroupSeparator } = this.props + const feature = this.props.data.find( (d) => d.properties.id === evt.feature.properties.id ) const data = getGeojsonDisplayData(feature).reduce( (acc, { dataKey, value }) => { - acc[dataKey] = value + acc[dataKey] = formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + ) return acc }, {} diff --git a/src/components/map/layers/TrackedEntityLayer.jsx b/src/components/map/layers/TrackedEntityLayer.jsx index 1ffa39578..966f8b4ac 100644 --- a/src/components/map/layers/TrackedEntityLayer.jsx +++ b/src/components/map/layers/TrackedEntityLayer.jsx @@ -140,7 +140,8 @@ class TrackedEntityLayer extends Layer { } render() { - const { program, nameProperty } = this.props + const { program, nameProperty, keyAnalysisDigitGroupSeparator } = + this.props const { popup, displayAttributes } = this.state return popup ? ( @@ -148,6 +149,7 @@ class TrackedEntityLayer extends Layer { {...popup} program={program} nameProperty={nameProperty} + keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator} displayAttributes={displayAttributes || []} onClose={this.onPopupClose} /> diff --git a/src/components/map/layers/TrackedEntityPopup.jsx b/src/components/map/layers/TrackedEntityPopup.jsx index 9260f29a8..8d88906f2 100644 --- a/src/components/map/layers/TrackedEntityPopup.jsx +++ b/src/components/map/layers/TrackedEntityPopup.jsx @@ -22,7 +22,12 @@ const TRACKED_ENTITIES_QUERY = { }, } -const getDataRows = ({ displayAttributes, attributes, orgUnitNames }) => { +const getDataRows = ({ + displayAttributes, + attributes, + orgUnitNames, + keyAnalysisDigitGroupSeparator, +}) => { const dataRows = [] // Include rows for each displayInList attribute @@ -33,6 +38,7 @@ const getDataRows = ({ displayAttributes, attributes, orgUnitNames }) => { valueType, options, orgUnitNames, + keyAnalysisDigitGroupSeparator, }) dataRows.push( @@ -57,6 +63,7 @@ const TrackedEntityPopup = ({ activeDataSource, program, nameProperty, + keyAnalysisDigitGroupSeparator, displayAttributes, onClose, }) => { @@ -154,6 +161,7 @@ const TrackedEntityPopup = ({ displayAttributes, attributes, orgUnitNames, + keyAnalysisDigitGroupSeparator, })} {type === 'Point' && ( @@ -187,6 +195,7 @@ TrackedEntityPopup.propTypes = { nameProperty: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, activeDataSource: PropTypes.string, + keyAnalysisDigitGroupSeparator: PropTypes.string, program: PropTypes.object, } diff --git a/src/components/orgunits/OrgUnitInfo.jsx b/src/components/orgunits/OrgUnitInfo.jsx index 35ab76c35..ebf07189c 100644 --- a/src/components/orgunits/OrgUnitInfo.jsx +++ b/src/components/orgunits/OrgUnitInfo.jsx @@ -3,8 +3,12 @@ import i18n from '@dhis2/d2-i18n' import { IconDimensionOrgUnit16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' -import { getRoundToPrecisionFn } from '../../util/numbers.js' +import { + getRoundToPrecisionFn, + formatWithSeparator, +} from '../../util/numbers.js' import { formatDate } from '../../util/time.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import ListItem from '../core/ListItem.jsx' import styles from './styles/OrgUnitInfo.module.css' @@ -41,6 +45,9 @@ const OrgUnitInfo = ({ url, }) => { const { baseUrl } = useConfig() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() return (
{imageId && ( @@ -111,7 +118,10 @@ const OrgUnitInfo = ({ {comment} {attributes.map(({ id, label, value }) => ( - {value} + {formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} ))}
diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index f4ce94194..d209e6f12 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -28,7 +28,10 @@ const LayerLoader = ({ config, onLoad }) => { const { baseUrl, serverVersion } = useConfig() const engine = useDataEngine() const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine)) - const { currentUser } = useCachedData() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + currentUser, + } = useCachedData() const { keyAnalysisDisplayProperty, id: userId } = currentUser const periodTypeData = useDataOutputPeriodTypes() @@ -45,6 +48,7 @@ const LayerLoader = ({ config, onLoad }) => { config, engine, keyAnalysisDisplayProperty, // name/shortName + keyAnalysisDigitGroupSeparator, // NONE/SPACE/COMMA userId, baseUrl, analyticsEngine, // Thematic and Event loader @@ -62,6 +66,7 @@ const LayerLoader = ({ config, onLoad }) => { userId, baseUrl, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, serverVersion, ]) diff --git a/src/constants/settings.js b/src/constants/settings.js index 3dde6d296..f3243b8fd 100644 --- a/src/constants/settings.js +++ b/src/constants/settings.js @@ -3,11 +3,17 @@ import { MAP_SERVICE_KEY_TESTS } from './layers.js' export const apiVersion = 40 +export const DIGIT_GROUP_SEPARATOR_SPACE = 'SPACE' +export const DIGIT_GROUP_SEPARATOR_COMMA = 'COMMA' +export const DIGIT_GROUP_SEPARATOR_NONE = 'NONE' + export const DEFAULT_SYSTEM_SETTINGS = { keyDefaultBaseMap: FALLBACK_BASEMAP_ID, + keyAnalysisDigitGroupSeparator: DIGIT_GROUP_SEPARATOR_NONE, } export const SYSTEM_SETTINGS = [ + 'keyAnalysisDigitGroupSeparator', 'keyAnalysisRelativePeriod', 'keyHideDailyPeriods', 'keyHideWeeklyPeriods', diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 55af2d7f1..bc7f0bbef 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -31,7 +31,10 @@ export const useLayersLoader = () => { const { baseUrl, serverVersion } = useConfig() const engine = useDataEngine() const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine)) - const { currentUser } = useCachedData() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + currentUser, + } = useCachedData() const { showAlerts } = useLoaderAlerts() const allLayers = useSelector((state) => state.map.mapViews) const dataTable = useSelector((state) => state.dataTable) @@ -50,6 +53,7 @@ export const useLayersLoader = () => { config, engine, keyAnalysisDisplayProperty, // name/shortName + keyAnalysisDigitGroupSeparator, // NONE/SPACE/COMMA userId, baseUrl, analyticsEngine, // Thematic and Event loader @@ -108,6 +112,7 @@ export const useLayersLoader = () => { allLayers, dispatch, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, userId, engine, analyticsEngine, diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 23e2725cd..b767ec8c9 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -14,7 +14,7 @@ import { } from '../util/earthEngine.js' import { sortLegendItems } from '../util/legend.js' import { toGeoJson } from '../util/map.js' -import { getRoundToPrecisionFn } from '../util/numbers.js' +import { getRoundToPrecisionFn, formatWithSeparator } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, @@ -25,6 +25,7 @@ const earthEngineLoader = async ({ config, engine, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, userId, }) => { const { format, rows, aggregationType } = config @@ -201,7 +202,11 @@ const earthEngineLoader = async ({ !hasClasses(aggregationType) && style?.palette ) { - legend.items = createLegend(style, !maskOperator) + legend.items = createLegend( + style, + !maskOperator, + keyAnalysisDigitGroupSeparator + ) } const filter = getStaticFilterFromPeriod(period, filters) @@ -211,6 +216,7 @@ const earthEngineLoader = async ({ legend, name, data, + keyAnalysisDigitGroupSeparator, filter, alerts, isLoaded: true, @@ -221,7 +227,11 @@ const earthEngineLoader = async ({ } } -export const createLegend = ({ min, max, palette, ranges }, showBelowMin) => { +export const createLegend = ( + { min, max, palette, ranges }, + showBelowMin, + keyAnalysisDigitGroupSeparator +) => { if (ranges && ranges.length === palette.length) { return sortLegendItems( ranges.map((range, index) => ({ @@ -246,16 +256,23 @@ export const createLegend = ({ min, max, palette, ranges }, showBelowMin) => { // Less than min item.from = -Infinity item.to = min - item.name = '< ' + min + item.name = + '< ' + + formatWithSeparator(min, keyAnalysisDigitGroupSeparator) to = min } else if (+from < max) { item.from = +from item.to = +to - item.name = from + ' - ' + to + item.name = + formatWithSeparator(from, keyAnalysisDigitGroupSeparator) + + ' - ' + + formatWithSeparator(to, keyAnalysisDigitGroupSeparator) } else { // Higher than max item.from = +from - item.name = '> ' + from + item.name = + '> ' + + formatWithSeparator(from, keyAnalysisDigitGroupSeparator) } from = to diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index f8dce4cdc..076775831 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -47,11 +47,15 @@ const eventLoader = async ({ config: layerConfig, engine, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, analyticsEngine, periodTypeData, loadExtended, }) => { - const config = { ...layerConfig } + const config = { + ...layerConfig, + keyAnalysisDigitGroupSeparator, + } const displayNameProp = keyAnalysisDisplayProperty === 'name' ? 'displayName' diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index b412f538c..43e832316 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -45,7 +45,12 @@ const fetchData = async (url, engine, baseUrl) => { } const EMPTY_FEATURE_STYLE = {} -const geoJsonUrlLoader = async ({ config: layer, engine, baseUrl }) => { +const geoJsonUrlLoader = async ({ + config: layer, + engine, + baseUrl, + keyAnalysisDigitGroupSeparator, +}) => { const { config } = layer let newConfig @@ -114,9 +119,10 @@ const geoJsonUrlLoader = async ({ config: layer, engine, baseUrl }) => { return { ...layer, - name: newConfig.name, // TODO - will be fixed by DHIS2-16088 + name: newConfig.name, // Overrides layer.name from spread — redundant on 2.42+ (DHIS2-16088), remove when 2.41 support is dropped legend, data, + keyAnalysisDigitGroupSeparator, config: newConfig, featureStyle, isLoaded: true, diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 3df3e197d..9d90abe84 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -34,6 +34,7 @@ import { getAutomaticLegendItems, } from '../util/legend.js' import { toGeoJson } from '../util/map.js' +import { formatWithSeparator } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, @@ -45,6 +46,7 @@ const thematicLoader = async ({ config, engine, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, userId, analyticsEngine, periodTypeData, @@ -301,7 +303,13 @@ const thematicLoader = async ({ ? ORG_UNIT_COLOR : legendItem.color properties.legend = legendItem.name // Shown in data table - properties.range = `${legendItem.startValue} - ${legendItem.endValue}` // Shown in data table + properties.range = `${formatWithSeparator( + legendItem.startValue, + keyAnalysisDigitGroupSeparator + )} - ${formatWithSeparator( + legendItem.endValue, + keyAnalysisDigitGroupSeparator + )}` // Shown in data table } // Only count org units once in legend @@ -312,7 +320,11 @@ const thematicLoader = async ({ } } - properties.value = value + properties.value = formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + ) // Shown in tooltip, label, pop-up, data table + properties.rawValue = value // Numeric form for data table sorting properties.radius = hasAdditionalGeometry ? ORG_UNIT_RADIUS_SMALL : getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index db9fc6bbe..706b52fe5 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -100,7 +100,16 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -const trackedEntityLoader = async ({ config, engine, serverVersion }) => { +const toGeoJson = (instances) => + instances.map(({ id, geometry }) => ({ + type: GEO_TYPE_FEATURE, + geometry, + properties: { + id, + }, + })) + +const parseJsonConfig = (config) => { if (config.config && typeof config.config === 'string') { try { const customConfig = JSON.parse(config.config) @@ -116,6 +125,113 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { } delete config.config } +} + +const fetchRelationshipData = async ({ + engine, + isVersion40, + instances, + relationshipTypeID, + orgUnits, + organisationUnitSelectionMode, + relatedPointColor, + relatedPointRadius, + relationshipLineColor, + legend, +}) => { + const { relationshipType } = await engine.query( + { relationshipType: RELATIONSHIP_TYPES_QUERY }, + { variables: { id: relationshipTypeID } } + ) + + const { relatedEntityType } = await engine.query( + { relatedEntityType: TRACKED_ENTITY_TYPES_QUERY }, + { + variables: { + id: relationshipType.toConstraint.trackedEntityType.id, + }, + } + ) + + const isPoint = + relatedEntityType.featureType === GEO_TYPE_POINT.toUpperCase() + + legend.items.push( + { + type: GEO_TYPE_LINE, + name: relationshipType.displayName, + color: relationshipLineColor || TEI_RELATIONSHIP_LINE_COLOR, + weight: 1, + }, + { + name: `${relatedEntityType.displayName} (${i18n.t('related')})`, + color: relatedPointColor || TEI_RELATED_COLOR, + radius: isPoint + ? relatedPointRadius || TEI_RELATED_RADIUS + : undefined, + weight: isPoint ? undefined : 1, + } + ) + + const dataWithRels = await getDataWithRelationships({ + isVersion40, + instances, + queryOptions: { + relationshipType, + orgUnits, + organisationUnitSelectionMode, + }, + engine, + }) + + return { + data: toGeoJson(dataWithRels.primary), + relationships: dataWithRels.relationships, + secondaryData: toGeoJson(dataWithRels.secondary), + } +} + +const buildQueryVariables = ({ + fields, + orgUnits, + orgUnitMode, + program, + programStatus, + followUp, + trackedEntityType, + periodType, + startDate, + endDate, +}) => { + const followUpBool = followUp ? 'TRUE' : 'FALSE' + const boolFollowUp = + program && followUp !== undefined ? followUpBool : undefined + + return { + fields, + orgUnits, + orgUnitMode, + program: program?.id, + programStatus, + followUp: boolFollowUp, + trackedEntityType: program ? undefined : trackedEntityType?.id, + enrollmentEnrolledAfter: + periodType === 'program' ? trimTime(startDate) : undefined, + enrollmentEnrolledBefore: + periodType === 'program' ? trimTime(endDate) : undefined, + updatedAfter: + periodType === 'program' ? undefined : trimTime(startDate), + updatedBefore: periodType === 'program' ? undefined : trimTime(endDate), + } +} + +const trackedEntityLoader = async ({ + config, + engine, + keyAnalysisDigitGroupSeparator, + serverVersion, +}) => { + parseJsonConfig(config) const { trackedEntityType, @@ -164,7 +280,6 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { const fieldsWithRelationships = [...fields, 'relationships'] let explanation - let boolFollowUp = undefined if (program && programStatus) { explanation = `${i18n.t('Program status')}: ${ @@ -172,30 +287,21 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { }` } - if (program && followUp !== undefined) { - boolFollowUp = followUp ? 'TRUE' : 'FALSE' - } - const { trackedEntities } = await engine.query( { trackedEntities: isVersion40 ? TEI_40_QUERY : TEI_41_QUERY }, { - variables: { + variables: buildQueryVariables({ fields: fieldsWithRelationships, - orgUnits: orgUnits, + orgUnits, orgUnitMode: organisationUnitSelectionMode, - program: program?.id, + program, programStatus, - followUp: boolFollowUp, - trackedEntityType: !program ? trackedEntityType?.id : undefined, - enrollmentEnrolledAfter: - periodType === 'program' ? trimTime(startDate) : undefined, - enrollmentEnrolledBefore: - periodType === 'program' ? trimTime(endDate) : undefined, - updatedAfter: - periodType !== 'program' ? trimTime(startDate) : undefined, - updatedBefore: - periodType !== 'program' ? trimTime(endDate) : undefined, - }, + followUp, + trackedEntityType, + periodType, + startDate, + endDate, + }), } ) @@ -219,58 +325,18 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { let data, relationships, secondaryData if (relationshipTypeID) { - const { relationshipType } = await engine.query( - { relationshipType: RELATIONSHIP_TYPES_QUERY }, - { - variables: { - id: relationshipTypeID, - }, - } - ) - - const { relatedEntityType } = await engine.query( - { relatedEntityType: TRACKED_ENTITY_TYPES_QUERY }, - { - variables: { - id: relationshipType.toConstraint.trackedEntityType.id, - }, - } - ) - - const isPoint = - relatedEntityType.featureType === GEO_TYPE_POINT.toUpperCase() - - legend.items.push( - { - type: GEO_TYPE_LINE, - name: relationshipType.displayName, - color: relationshipLineColor || TEI_RELATIONSHIP_LINE_COLOR, - weight: 1, - }, - { - name: `${relatedEntityType.displayName} (${i18n.t('related')})`, - color: relatedPointColor || TEI_RELATED_COLOR, - radius: isPoint - ? relatedPointRadius || TEI_RELATED_RADIUS - : undefined, - weight: !isPoint ? 1 : undefined, - } - ) - - const dataWithRels = await getDataWithRelationships({ + ;({ data, relationships, secondaryData } = await fetchRelationshipData({ + engine, isVersion40, instances, - queryOptions: { - relationshipType, - orgUnits, - organisationUnitSelectionMode, - }, - engine, - }) - - data = toGeoJson(dataWithRels.primary) - relationships = dataWithRels.relationships - secondaryData = toGeoJson(dataWithRels.secondary) + relationshipTypeID, + orgUnits, + organisationUnitSelectionMode, + relatedPointColor, + relatedPointRadius, + relationshipLineColor, + legend, + })) } else { data = toGeoJson(instances) } @@ -283,6 +349,7 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { ...config, name, data, + keyAnalysisDigitGroupSeparator, relationships, secondaryData, legend, @@ -294,13 +361,4 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { } } -const toGeoJson = (instances) => - instances.map(({ id, geometry }) => ({ - type: GEO_TYPE_FEATURE, - geometry, - properties: { - id, - }, - })) - export default trackedEntityLoader diff --git a/src/util/__tests__/numbers.spec.js b/src/util/__tests__/numbers.spec.js index 7bbb952f1..233ad2ab7 100644 --- a/src/util/__tests__/numbers.spec.js +++ b/src/util/__tests__/numbers.spec.js @@ -1,4 +1,14 @@ -import { formatCount, getPrecision } from '../numbers.js' +import { + DIGIT_GROUP_SEPARATOR_COMMA, + DIGIT_GROUP_SEPARATOR_NONE, + DIGIT_GROUP_SEPARATOR_SPACE, +} from '../../constants/settings.js' +import { + formatCount, + getPrecision, + formatWithSeparator, + parseWithSeparator, +} from '../numbers.js' describe('numbers', () => { describe('formatCount', () => { @@ -80,4 +90,117 @@ describe('numbers', () => { expect(getPrecision([-100.67, -100.1, -100.2, -100.3])).toEqual(2) }) }) + + describe('formatWithSeparator', () => { + it('formats positive integers with comma separator', () => { + expect( + formatWithSeparator(1234567, DIGIT_GROUP_SEPARATOR_COMMA) + ).toBe('1,234,567') + }) + + it('formats positive integers with space separator', () => { + expect( + formatWithSeparator(1234567, DIGIT_GROUP_SEPARATOR_SPACE) + ).toBe('1 234 567') + }) + + it('does not group with NONE separator', () => { + expect( + formatWithSeparator(1234567, DIGIT_GROUP_SEPARATOR_NONE) + ).toBe('1234567') + }) + + it('handles negative numbers', () => { + expect( + formatWithSeparator(-1234567, DIGIT_GROUP_SEPARATOR_COMMA) + ).toBe('-1,234,567') + }) + + it('preserves decimals', () => { + expect( + formatWithSeparator(1234.56, DIGIT_GROUP_SEPARATOR_COMMA) + ).toBe('1,234.56') + }) + + it('applies precision when specified', () => { + expect( + formatWithSeparator(1234.5, DIGIT_GROUP_SEPARATOR_COMMA, { + precision: 3, + }) + ).toBe('1,234.500') + }) + + it('handles zero', () => { + expect(formatWithSeparator(0, DIGIT_GROUP_SEPARATOR_COMMA)).toBe( + '0' + ) + }) + + it('handles numbers below 1000 without grouping', () => { + expect(formatWithSeparator(42, DIGIT_GROUP_SEPARATOR_COMMA)).toBe( + '42' + ) + }) + + it('returns non-numeric input unchanged', () => { + expect( + formatWithSeparator('hello', DIGIT_GROUP_SEPARATOR_COMMA) + ).toBe('hello') + expect( + formatWithSeparator(null, DIGIT_GROUP_SEPARATOR_COMMA) + ).toBeNull() + expect( + formatWithSeparator(undefined, DIGIT_GROUP_SEPARATOR_COMMA) + ).toBeUndefined() + }) + + it('forces formatting of numeric strings when force: true', () => { + expect( + formatWithSeparator('1234.56', DIGIT_GROUP_SEPARATOR_COMMA, { + force: true, + }) + ).toBe('1,234.56') + }) + + it('treats unknown separator values as NONE', () => { + expect(formatWithSeparator(1234, 'WEIRD')).toBe( + formatWithSeparator(1234, DIGIT_GROUP_SEPARATOR_NONE) + ) + }) + }) + + describe('parseWithSeparator', () => { + it('parses comma-separated integers', () => { + expect(parseWithSeparator('1,234,567')).toBe(1234567) + }) + + it('parses space-separated integers', () => { + expect(parseWithSeparator('1 234 567')).toBe(1234567) + }) + + it('parses values with decimals', () => { + expect(parseWithSeparator('1,234.56')).toBe(1234.56) + }) + + it('parses negative values', () => { + expect(parseWithSeparator('-1,234')).toBe(-1234) + }) + + it('returns undefined for non-numeric input', () => { + expect(parseWithSeparator('abc')).toBeUndefined() + }) + + it('round-trips with formatWithSeparator', () => { + expect( + parseWithSeparator( + formatWithSeparator(1234567, DIGIT_GROUP_SEPARATOR_COMMA) + ) + ).toBe(1234567) + expect( + parseWithSeparator( + formatWithSeparator(1234.56, DIGIT_GROUP_SEPARATOR_SPACE) + ) + ).toBe(1234.56) + }) + }) }) diff --git a/src/util/helpers.js b/src/util/helpers.js index 31a2a0e8a..abde96e95 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -6,8 +6,10 @@ import { dateValueTypes, datetimeValueTypes, coordinateValueTypes, + numberValueTypes, ouValueTypes, } from '../constants/valueTypes.js' +import { formatWithSeparator } from './numbers.js' const getBaseFields = (withSubscribers) => { const baseFields = [ @@ -144,7 +146,7 @@ export const formatCoordinate = (value) => { if ( Array.isArray(array) && array.length === 2 && - array.every((v) => !isNaN(Number(v))) + array.every((v) => !Number.isNaN(Number(v))) ) { return array.map((v) => Number(v).toFixed(6)).join(', ') } @@ -193,6 +195,7 @@ export const formatValueForDisplay = ({ valueType, options, orgUnitNames, + keyAnalysisDigitGroupSeparator, }) => { if (!hasValue(value)) { return i18n.t('Not set') @@ -224,7 +227,11 @@ export const formatValueForDisplay = ({ if (datetimeValueTypes.includes(valueType)) { return formatDatetime(value) } - // TODO formatNumeric + if (numberValueTypes.includes(valueType)) { + return formatWithSeparator(value, keyAnalysisDigitGroupSeparator, { + force: true, + }) + } return value } @@ -246,5 +253,5 @@ export const getCssVar = (cssVar) => Number( getComputedStyle(document.documentElement) .getPropertyValue(cssVar) - .replace('px', '') + .replaceAll('px', '') ) diff --git a/src/util/numbers.js b/src/util/numbers.js index eda577aa4..66a7d4420 100644 --- a/src/util/numbers.js +++ b/src/util/numbers.js @@ -1,3 +1,9 @@ +import { + DIGIT_GROUP_SEPARATOR_SPACE, + DIGIT_GROUP_SEPARATOR_COMMA, + DIGIT_GROUP_SEPARATOR_NONE, +} from '../constants/settings.js' + export const formatCount = (count) => { let num @@ -57,3 +63,38 @@ export const getPrecision = (values = []) => { return 0 } + +const DIGIT_GROUP_SEPARATORS = { + [DIGIT_GROUP_SEPARATOR_SPACE]: ' ', + [DIGIT_GROUP_SEPARATOR_COMMA]: ',', + [DIGIT_GROUP_SEPARATOR_NONE]: '', +} + +export const formatWithSeparator = ( + value, + separator, + { force = false, precision } = {} +) => { + if (!force && typeof value !== 'number') { + return value + } + const sep = DIGIT_GROUP_SEPARATORS[separator] ?? '' + const formatted = + precision === undefined + ? String(value) + : Number(value).toFixed(precision) + const [integer, decimal] = formatted.split('.') + const isNegative = integer.startsWith('-') + const digits = isNegative ? integer.slice(1) : integer + const groups = [] + for (let i = digits.length; i > 0; i -= 3) { + groups.unshift(digits.slice(Math.max(0, i - 3), i)) + } + const grouped = (isNegative ? '-' : '') + groups.join(sep) + return decimal ? `${grouped}.${decimal}` : grouped +} + +export const parseWithSeparator = (value) => { + const num = Number(String(value).replaceAll(/[\s,]/g, '')) + return Number.isNaN(num) ? undefined : num +} From 90364ee7cd8e243818baf975cd724cf61b8b8e9f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 11:10:50 +0200 Subject: [PATCH 2/6] chore: add tests --- .../datatable/__tests__/useTableData.spec.jsx | 39 ++++---- .../orgunits/__tests__/OrgUnitInfo.spec.jsx | 6 ++ .../__tests__/earthEngineLoader.spec.js | 89 +++++++++++++++++++ src/util/__tests__/helpers.spec.js | 65 ++++++++++++++ 4 files changed, 180 insertions(+), 19 deletions(-) create mode 100644 src/loaders/__tests__/earthEngineLoader.spec.js diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 75076ecf9..1340843b7 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -136,7 +136,8 @@ describe('useTableData headers', () => { color: '#FFFFB2', legend: 'Great', range: '90 - 120', - value: 106.3, + value: '106.3', + rawValue: 106.3, }, }, ], @@ -160,7 +161,7 @@ describe('useTableData headers', () => { { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, - { name: 'Value', dataKey: 'value', type: 'number' }, + { name: 'Value', dataKey: 'rawValue', type: 'number' }, { name: 'Legend', dataKey: 'legend', type: 'string' }, { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Level', dataKey: 'level', type: 'number' }, @@ -179,7 +180,7 @@ describe('useTableData headers', () => { { value: 0, dataKey: 'index' }, { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, - { value: 106.3, dataKey: 'value' }, + { value: 106.3, dataKey: 'rawValue' }, { value: 'Great', dataKey: 'legend' }, { value: '90 - 120', dataKey: 'range' }, { value: 4, dataKey: 'level' }, @@ -544,11 +545,11 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Item A', value: 10 } }, - { id: '2', properties: { name: 'Item B', value: 5 } }, - { id: '3', properties: { name: 'Item C', value: undefined } }, - { id: '4', properties: { name: 'Item D', value: 15 } }, - { id: '5', properties: { name: 'Item E', value: undefined } }, + { id: '1', properties: { name: 'Item A', rawValue: 10 } }, + { id: '2', properties: { name: 'Item B', rawValue: 5 } }, + { id: '3', properties: { name: 'Item C', rawValue: undefined } }, + { id: '4', properties: { name: 'Item D', rawValue: 15 } }, + { id: '5', properties: { name: 'Item E', rawValue: undefined } }, ], } @@ -560,7 +561,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: mockLayer, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'asc', }), { @@ -582,7 +583,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: mockLayer, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'desc', }), { @@ -670,16 +671,16 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Item A', value: 10 } }, + { id: '1', properties: { name: 'Item A', rawValue: 10 } }, { id: '2', - properties: { name: 'Item B', value: undefined }, + properties: { name: 'Item B', rawValue: undefined }, }, { id: '3', - properties: { name: 'Item C', value: undefined }, + properties: { name: 'Item C', rawValue: undefined }, }, - { id: '4', properties: { name: 'Item D', value: 5 } }, + { id: '4', properties: { name: 'Item D', rawValue: 5 } }, ], } @@ -690,7 +691,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithManyUndefined, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'asc', }), { @@ -712,15 +713,15 @@ describe('useTableData sorting', () => { data: [ { id: '1', - properties: { name: 'Item A', value: undefined }, + properties: { name: 'Item A', rawValue: undefined }, }, { id: '2', - properties: { name: 'Item B', value: undefined }, + properties: { name: 'Item B', rawValue: undefined }, }, { id: '3', - properties: { name: 'Item C', value: undefined }, + properties: { name: 'Item C', rawValue: undefined }, }, ], } @@ -732,7 +733,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithAllUndefined, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'asc', }), { diff --git a/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx b/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx index 34829f9b1..f9ad1ee2b 100644 --- a/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx +++ b/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx @@ -6,6 +6,12 @@ jest.mock('@dhis2/app-runtime', () => ({ useConfig: jest.fn(() => ({ baseUrl: 'dhis2' })), })) +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: jest.fn(() => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'NONE' }, + })), +})) + const groupSets = [ { id: 'Bpx0589u8y0', diff --git a/src/loaders/__tests__/earthEngineLoader.spec.js b/src/loaders/__tests__/earthEngineLoader.spec.js new file mode 100644 index 000000000..fda7b7bf0 --- /dev/null +++ b/src/loaders/__tests__/earthEngineLoader.spec.js @@ -0,0 +1,89 @@ +import { + DIGIT_GROUP_SEPARATOR_COMMA, + DIGIT_GROUP_SEPARATOR_NONE, + DIGIT_GROUP_SEPARATOR_SPACE, +} from '../../constants/settings.js' +import { createLegend } from '../earthEngineLoader.js' + +jest.mock('../../components/map/MapApi.js', () => ({ + loadEarthEngineWorker: jest.fn(), +})) + +describe('createLegend', () => { + describe('when ranges are provided', () => { + it('maps palette colors to ranges and ignores separator', () => { + const style = { + min: 0, + max: 100, + palette: ['red', 'blue'], + ranges: [ + { startValue: 0, endValue: 50, name: 'Low' }, + { startValue: 50, endValue: 100, name: 'High' }, + ], + } + const items = createLegend( + style, + false, + DIGIT_GROUP_SEPARATOR_COMMA + ) + expect(items).toHaveLength(2) + const low = items.find((i) => i.name === 'Low') + const high = items.find((i) => i.name === 'High') + expect(low.color).toBe('red') + expect(high.color).toBe('blue') + }) + }) + + describe('when ranges are not provided', () => { + const style = { min: 1000, max: 3000, palette: ['#a', '#b'] } + + it('formats item names with COMMA separator', () => { + const items = createLegend( + style, + false, + DIGIT_GROUP_SEPARATOR_COMMA + ) + const names = items.map((i) => i.name) + expect(names).toContain('1,000 - 3,000') + expect(names).toContain('> 3,000') + }) + + it('formats item names with SPACE separator', () => { + const items = createLegend( + style, + false, + DIGIT_GROUP_SEPARATOR_SPACE + ) + const names = items.map((i) => i.name) + expect(names).toContain('1 000 - 3 000') + expect(names).toContain('> 3 000') + }) + + it('does not group digits with NONE separator', () => { + const items = createLegend(style, false, DIGIT_GROUP_SEPARATOR_NONE) + const names = items.map((i) => i.name) + expect(names).toContain('1000 - 3000') + expect(names).toContain('> 3000') + }) + + it('includes a "less than min" item when showBelowMin is true', () => { + const items = createLegend( + { min: 1000, max: 3000, palette: ['#a', '#b', '#c'] }, + true, + DIGIT_GROUP_SEPARATOR_COMMA + ) + const belowMin = items.find((i) => i.from === -Infinity) + expect(belowMin).toBeDefined() + expect(belowMin.name).toBe('< 1,000') + }) + + it('sets correct from/to boundaries for range items', () => { + const items = createLegend(style, false, DIGIT_GROUP_SEPARATOR_NONE) + const rangeItem = items.find( + (i) => i.from !== undefined && i.to !== undefined + ) + expect(rangeItem.from).toBe(1000) + expect(rangeItem.to).toBe(3000) + }) + }) +}) diff --git a/src/util/__tests__/helpers.spec.js b/src/util/__tests__/helpers.spec.js index f3b5cdce0..dd74ef5d4 100644 --- a/src/util/__tests__/helpers.spec.js +++ b/src/util/__tests__/helpers.spec.js @@ -1,3 +1,8 @@ +import { + DIGIT_GROUP_SEPARATOR_COMMA, + DIGIT_GROUP_SEPARATOR_NONE, + DIGIT_GROUP_SEPARATOR_SPACE, +} from '../../constants/settings.js' import { formatValueForDisplay, sumObjectValues } from '../helpers.js' describe('formatValueForDisplay', () => { @@ -138,6 +143,66 @@ describe('formatValueForDisplay', () => { expect(formatValueForDisplay(input)).toBe(expected) }) + describe('formats number value types with digit group separator', () => { + it.each([ + 'NUMBER', + 'INTEGER', + 'INTEGER_POSITIVE', + 'INTEGER_NEGATIVE', + 'INTEGER_ZERO_OR_POSITIVE', + 'PERCENTAGE', + 'UNIT_INTERVAL', + ])('formats %s with COMMA separator', (valueType) => { + expect( + formatValueForDisplay({ + value: '1234567', + valueType, + keyAnalysisDigitGroupSeparator: DIGIT_GROUP_SEPARATOR_COMMA, + }) + ).toBe('1,234,567') + }) + + it('formats NUMBER with SPACE separator', () => { + expect( + formatValueForDisplay({ + value: '1234567', + valueType: 'NUMBER', + keyAnalysisDigitGroupSeparator: DIGIT_GROUP_SEPARATOR_SPACE, + }) + ).toBe('1 234 567') + }) + + it('returns plain value with NONE separator', () => { + expect( + formatValueForDisplay({ + value: '1234567', + valueType: 'NUMBER', + keyAnalysisDigitGroupSeparator: DIGIT_GROUP_SEPARATOR_NONE, + }) + ).toBe('1234567') + }) + + it('preserves decimal part', () => { + expect( + formatValueForDisplay({ + value: '1234.56', + valueType: 'NUMBER', + keyAnalysisDigitGroupSeparator: DIGIT_GROUP_SEPARATOR_COMMA, + }) + ).toBe('1,234.56') + }) + + it('does not apply separator to non-number types like TEXT', () => { + expect( + formatValueForDisplay({ + value: '1234567', + valueType: 'TEXT', + keyAnalysisDigitGroupSeparator: DIGIT_GROUP_SEPARATOR_COMMA, + }) + ).toBe('1234567') + }) + }) + it('returns raw value for other DHIS2 types not specially handled', () => { const samplesByType = { TEXT: 'Hello world', From 1e3ac480e5974466ffb9798c1df9f336ef714e09 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 12:28:58 +0200 Subject: [PATCH 3/6] fix: apply digit group separator formatting before bubble legend layout computation [DHIS2-18963] --- i18n/en.pot | 10 +++++----- src/components/legend/Bubble.jsx | 11 +---------- src/components/legend/Bubbles.jsx | 17 +++++++++++++++++ src/util/__tests__/bubbles.spec.js | 1 + src/util/bubbles.js | 14 ++++++++++---- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 2b93f9cbd..c495195fb 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-04-24T10:52:00.804Z\n" -"PO-Revision-Date: 2026-04-24T10:52:00.804Z\n" +"POT-Creation-Date: 2026-04-26T10:03:57.925Z\n" +"PO-Revision-Date: 2026-04-26T10:03:57.925Z\n" msgid "2020" msgstr "2020" @@ -1758,12 +1758,12 @@ msgstr "Data item was not found" msgid "Thematic layer" msgstr "Thematic layer" -msgid "Tracked entity" -msgstr "Tracked entity" - msgid "related" msgstr "related" +msgid "Tracked entity" +msgstr "Tracked entity" + msgid "not one of" msgstr "not one of" diff --git a/src/components/legend/Bubble.jsx b/src/components/legend/Bubble.jsx index 7db2b0f85..5d84adb7f 100644 --- a/src/components/legend/Bubble.jsx +++ b/src/components/legend/Bubble.jsx @@ -1,7 +1,5 @@ import PropTypes from 'prop-types' import React from 'react' -import { formatWithSeparator } from '../../util/numbers.js' -import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { guideLength, textPadding } from './Bubbles.jsx' const Bubble = ({ @@ -13,9 +11,6 @@ const Bubble = ({ stroke, pattern, }) => { - const { - systemSettings: { keyAnalysisDigitGroupSeparator }, - } = useCachedData() const leftAlign = textAlign === 'left' const x = maxRadius const y = maxRadius * 2 - radius @@ -55,11 +50,7 @@ const Bubble = ({ alignmentBaseline="middle" style={{ fontSize: 12 }} > - {formatWithSeparator( - text, - keyAnalysisDigitGroupSeparator, - { force: true } - )} + {text} )} diff --git a/src/components/legend/Bubbles.jsx b/src/components/legend/Bubbles.jsx index 1e107069f..ef1128496 100644 --- a/src/components/legend/Bubbles.jsx +++ b/src/components/legend/Bubbles.jsx @@ -7,6 +7,8 @@ import { createSingleColorBubbles, computeLayout, } from '../../util/bubbles.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Bubble from './Bubble.jsx' const style = { @@ -25,6 +27,9 @@ const Bubbles = ({ classes, isPlugin, }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const legendWidth = isPlugin ? 150 : 245 const noDataClass = classes.find((c) => c.noData === true) const bubbleClasses = classes.filter((c) => !c.noData) @@ -53,6 +58,18 @@ const Bubbles = ({ radiusHigh, }) + bubbles.forEach((bubble) => { + if (bubble.text !== undefined) { + bubble.text = formatWithSeparator( + bubble.text, + keyAnalysisDigitGroupSeparator, + { + force: true, + } + ) + } + }) + const { alternate, offset, showNumbers } = computeLayout({ bubbles, bubbleClasses, diff --git a/src/util/__tests__/bubbles.spec.js b/src/util/__tests__/bubbles.spec.js index 7e5a16191..9d3f8691a 100644 --- a/src/util/__tests__/bubbles.spec.js +++ b/src/util/__tests__/bubbles.spec.js @@ -28,6 +28,7 @@ jest.mock('../helpers.js', () => ({ })) jest.mock('../numbers.js', () => ({ + formatWithSeparator: jest.fn((n) => String(n)), getRoundToPrecisionFn: jest.fn(() => (n) => n), })) diff --git a/src/util/bubbles.js b/src/util/bubbles.js index 250061cbc..048866741 100644 --- a/src/util/bubbles.js +++ b/src/util/bubbles.js @@ -7,7 +7,7 @@ import { } from '../components/legend/Bubbles.jsx' import { getContrastColor } from './colors.js' import { getLongestTextLength } from './helpers.js' -import { getRoundToPrecisionFn } from './numbers.js' +import { formatWithSeparator, getRoundToPrecisionFn } from './numbers.js' const getBubbleValueFormat = ({ minValue, maxValue, divisor }) => { if (minValue === maxValue) { @@ -111,12 +111,18 @@ export const computeLayout = ({ bubbleClasses, radiusHigh, legendWidth, + keyAnalysisDigitGroupSeparator, }) => { - // Calculate the pixel length of the longest number + // Calculate the pixel length of the longest formatted number + const formattedLen = (v) => + typeof v === 'number' + ? formatWithSeparator(v, keyAnalysisDigitGroupSeparator).length + : 0 let textLength = Math.ceil( Math.max( - getLongestTextLength(bubbleClasses, 'startValue'), - getLongestTextLength(bubbleClasses, 'endValue') + 0, + ...bubbleClasses.map((c) => formattedLen(c.startValue)), + ...bubbleClasses.map((c) => formattedLen(c.endValue)) ) * digitWidth ) From 2853a012981e05d57909e64b11ff6e25abe9e4de Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 13:06:54 +0200 Subject: [PATCH 4/6] fix: apply digit group separator to org unit profile data items [DHIS2-18963] --- src/components/orgunits/OrgUnitData.jsx | 12 +++++++++++- src/util/__tests__/bubbles.spec.js | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/orgunits/OrgUnitData.jsx b/src/components/orgunits/OrgUnitData.jsx index 1d271050a..8a8580957 100644 --- a/src/components/orgunits/OrgUnitData.jsx +++ b/src/components/orgunits/OrgUnitData.jsx @@ -3,10 +3,12 @@ import i18n from '@dhis2/d2-i18n' import { CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useState, useEffect } from 'react' +import { formatWithSeparator } from '../../util/numbers.js' import { getFixedPeriodsByType, filterFuturePeriods, } from '../../util/periods.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import PeriodSelect from '../periods/PeriodSelect.jsx' import styles from './styles/OrgUnitData.module.css' @@ -31,6 +33,9 @@ const defaultPeriod = filterFuturePeriods(periods)[0] || periods[0] * (data elements, indicators, reporting rates, program indicators) */ const OrgUnitData = ({ id }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const [period, setPeriod] = useState(defaultPeriod) const { loading, data, refetch } = useDataQuery(ORGUNIT_PROFILE_QUERY, { lazy: true, @@ -69,7 +74,12 @@ const OrgUnitData = ({ id }) => { ({ id, label, value }) => ( {label} - {value} + + {formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + ) )} diff --git a/src/util/__tests__/bubbles.spec.js b/src/util/__tests__/bubbles.spec.js index 9d3f8691a..23916498f 100644 --- a/src/util/__tests__/bubbles.spec.js +++ b/src/util/__tests__/bubbles.spec.js @@ -28,7 +28,7 @@ jest.mock('../helpers.js', () => ({ })) jest.mock('../numbers.js', () => ({ - formatWithSeparator: jest.fn((n) => String(n)), + formatWithSeparator: jest.fn(String), getRoundToPrecisionFn: jest.fn(() => (n) => n), })) From 070dd74d6f0cfd3682856a99f25be4a517add671 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 13:31:06 +0200 Subject: [PATCH 5/6] fix: apply digit group separator to Earth Engine popup values [DHIS2-18963] --- i18n/en.pot | 12 +++++- .../layers/earthEngine/EarthEngineLayer.jsx | 10 ++++- .../layers/earthEngine/EarthEnginePopup.jsx | 42 +++++++++++++------ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c495195fb..545bed738 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-04-26T10:03:57.925Z\n" -"PO-Revision-Date: 2026-04-26T10:03:57.925Z\n" +"POT-Creation-Date: 2026-04-26T11:10:26.962Z\n" +"PO-Revision-Date: 2026-04-26T11:10:26.963Z\n" msgid "2020" msgstr "2020" @@ -1824,3 +1824,11 @@ msgstr "End date is invalid" msgid "End date cannot be earlier than start date" msgstr "End date cannot be earlier than start date" + +msgctxt "Application title" +msgid "__MANIFEST_APP_TITLE" +msgstr "Maps" + +msgctxt "Application description" +msgid "__MANIFEST_APP_DESCRIPTION" +msgstr "DHIS2 Maps" diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 2dd758f17..02b47d492 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -216,7 +216,12 @@ export default class EarthEngineLayer extends Layer { } render() { - const { legend, aggregationType, loadError } = this.props + const { + legend, + aggregationType, + keyAnalysisDigitGroupSeparator, + loadError, + } = this.props const { isLoading, popup, aggregations, error } = this.state return ( @@ -235,6 +240,9 @@ export default class EarthEngineLayer extends Layer { legend={legend} valueType={aggregationType} onClose={this.onPopupClose} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } {...popup} /> )} diff --git a/src/components/map/layers/earthEngine/EarthEnginePopup.jsx b/src/components/map/layers/earthEngine/EarthEnginePopup.jsx index 4970c4830..3833ebc63 100644 --- a/src/components/map/layers/earthEngine/EarthEnginePopup.jsx +++ b/src/components/map/layers/earthEngine/EarthEnginePopup.jsx @@ -7,13 +7,29 @@ import { hasClasses } from '../../../../util/earthEngine.js' import { getRoundToPrecisionFn, getPrecision, + formatWithSeparator, } from '../../../../util/numbers.js' import Popup from '../../Popup.jsx' import styles from '../styles/Popup.module.css' import earthEngineStyles from './styles/EarthEnginePopup.module.css' +const getValuesForType = (data, type) => + Object.values(data).flatMap((ou) => + Object.entries(ou) + .filter(([key]) => key.includes(type)) + .map(([, val]) => val) + ) + const EarthEnginePopup = (props) => { - const { coordinates, feature, data, legend, valueType, onClose } = props + const { + coordinates, + feature, + data, + legend, + valueType, + onClose, + keyAnalysisDigitGroupSeparator, + } = props const { id, name } = feature.properties const { title, unit, items = [], groups } = legend const values = typeof data === 'object' ? data[id] : null @@ -24,7 +40,11 @@ const EarthEnginePopup = (props) => { if (values) { if (classes) { - const valueFormat = getRoundToPrecisionFn(isPercentage ? 2 : 0) + const valueFormat = (value) => + formatWithSeparator( + getRoundToPrecisionFn(isPercentage ? 2 : 0)(value), + keyAnalysisDigitGroupSeparator + ) table = ( @@ -70,17 +90,12 @@ const EarthEnginePopup = (props) => { : `${group}_${type}` // Returns the value format (precision) for an aggregation type - const getValueFormat = (type) => - getRoundToPrecisionFn( - getPrecision( - Object.values(data) - .map((ou) => - Object.keys(ou) - .filter((key) => key.includes(type)) - .map((key) => ou[key]) - ) - .flat() - ) + const getValueFormat = (type) => (value) => + formatWithSeparator( + getRoundToPrecisionFn( + getPrecision(getValuesForType(data, type)) + )(value), + keyAnalysisDigitGroupSeparator ) // Create value format function for each aggregation type @@ -191,6 +206,7 @@ EarthEnginePopup.propTypes = { legend: PropTypes.object.isRequired, onClose: PropTypes.func.isRequired, data: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + keyAnalysisDigitGroupSeparator: PropTypes.string, valueType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), } From 960803b7d9da1d7064b9232d4a4e7ec4343de34a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 14 May 2026 13:47:08 +0200 Subject: [PATCH 6/6] chore: use VERSION-TOGGLE convention for DHIS2-16088 comment --- src/loaders/geoJsonUrlLoader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index 43e832316..9b10fe572 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -119,7 +119,7 @@ const geoJsonUrlLoader = async ({ return { ...layer, - name: newConfig.name, // Overrides layer.name from spread — redundant on 2.42+ (DHIS2-16088), remove when 2.41 support is dropped + name: newConfig.name, // VERSION-TOGGLE: remove when 41 is lowest supported version, overrides layer.name from spread (DHIS2-16088) legend, data, keyAnalysisDigitGroupSeparator,