diff --git a/i18n/en.pot b/i18n/en.pot
index 2b93f9cbd..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-24T10:52:00.804Z\n"
-"PO-Revision-Date: 2026-04-24T10:52:00.804Z\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"
@@ -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"
@@ -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/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/__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/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/Bubbles.jsx b/src/components/legend/Bubbles.jsx
index b535254b8..912041d72 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 = {
@@ -74,6 +76,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)
@@ -107,6 +112,18 @@ const Bubbles = ({
radiusHigh,
legendWidth,
}))
+
+ bubbles.forEach((bubble) => {
+ if (bubble.text !== undefined) {
+ bubble.text = formatWithSeparator(
+ bubble.text,
+ keyAnalysisDigitGroupSeparator,
+ {
+ force: true,
+ }
+ )
+ }
+ })
}
const xTranslate = alternate ? offset : '2'
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/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]),
}
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/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/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/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/__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/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..9b10fe572 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, // VERSION-TOGGLE: remove when 41 is lowest supported version, overrides layer.name from spread (DHIS2-16088)
legend,
data,
+ keyAnalysisDigitGroupSeparator,
config: newConfig,
featureStyle,
isLoaded: true,
diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js
index c361576d0..397d0eba5 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,
@@ -305,7 +307,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
@@ -316,7 +324,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__/bubbles.spec.js b/src/util/__tests__/bubbles.spec.js
index 7e5a16191..23916498f 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(String),
getRoundToPrecisionFn: jest.fn(() => (n) => n),
}))
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',
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/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
)
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
+}