+
{i18n.t('Related entity style')}:
{i18n.t(
diff --git a/src/components/groupSet/StyleByGroupSet.jsx b/src/components/groupSet/StyleByGroupSet.jsx
index ab937b287..a7a5dab2a 100644
--- a/src/components/groupSet/StyleByGroupSet.jsx
+++ b/src/components/groupSet/StyleByGroupSet.jsx
@@ -41,7 +41,7 @@ StyleByGroupSet.propTypes = {
export default connect(
({ layerEdit }) => ({
- groupSet: layerEdit.organisationUnitGroupSet,
+ groupSet: layerEdit?.organisationUnitGroupSet,
}),
{ setOrganisationUnitGroupSet }
)(StyleByGroupSet)
diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx
index 252c42409..748a44c2c 100644
--- a/src/components/legend/Legend.jsx
+++ b/src/components/legend/Legend.jsx
@@ -20,6 +20,9 @@ const Legend = ({
source,
sourceUrl,
decimalPlaces,
+ eventsWithoutCoordinatesCount,
+ orgUnitsWithoutCoordinatesCount,
+ orgUnitsPointOnly = false,
isPlugin = false,
}) => {
const showRange = Array.isArray(items) && !legendNamesContainRange(items)
@@ -92,6 +95,42 @@ const Legend = ({
))}
)}
+ {(typeof eventsWithoutCoordinatesCount === 'number' ||
+ typeof orgUnitsWithoutCoordinatesCount === 'number') && (
+
+
{i18n.t('Data quality')}:
+ {typeof eventsWithoutCoordinatesCount === 'number' && (
+
+ {i18n.t('{{count}} event without coordinates', {
+ count: eventsWithoutCoordinatesCount,
+ defaultValue_plural:
+ '{{count}} events without coordinates',
+ })}
+
+ )}
+ {typeof orgUnitsWithoutCoordinatesCount === 'number' && (
+
+ {orgUnitsPointOnly
+ ? i18n.t(
+ '{{count}} org unit without a point location',
+ {
+ count: orgUnitsWithoutCoordinatesCount,
+ defaultValue_plural:
+ '{{count}} org units without a point location',
+ }
+ )
+ : i18n.t(
+ '{{count}} org unit without coordinates',
+ {
+ count: orgUnitsWithoutCoordinatesCount,
+ defaultValue_plural:
+ '{{count}} org units without coordinates',
+ }
+ )}
+
+ )}
+
+ )}
{source && (
{i18n.t('Source')}:
@@ -117,11 +156,14 @@ Legend.propTypes = {
coordinateFields: PropTypes.array,
decimalPlaces: PropTypes.number,
description: PropTypes.string,
+ eventsWithoutCoordinatesCount: PropTypes.number,
explanation: PropTypes.array,
filters: PropTypes.array,
groups: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
isPlugin: PropTypes.bool,
items: PropTypes.array,
+ orgUnitsPointOnly: PropTypes.bool,
+ orgUnitsWithoutCoordinatesCount: PropTypes.number,
source: PropTypes.string,
sourceUrl: PropTypes.string,
unit: PropTypes.string,
diff --git a/src/components/legend/styles/Legend.module.css b/src/components/legend/styles/Legend.module.css
index cf80b8027..3f8fdd7c6 100644
--- a/src/components/legend/styles/Legend.module.css
+++ b/src/components/legend/styles/Legend.module.css
@@ -21,13 +21,15 @@
}
.filters,
-.coordinateFields {
+.coordinateFields,
+.dataQuality {
padding-top: var(--spacers-dp16);
font-size: 12px;
}
.filters > div:first-child,
-.coordinateFields > div:first-child {
+.coordinateFields > div:first-child,
+.dataQuality > div:first-child {
font-weight: bold;
}
diff --git a/src/components/orgunits/OrgUnitSelect.jsx b/src/components/orgunits/OrgUnitSelect.jsx
index 5d56b135a..2be92ee6b 100644
--- a/src/components/orgunits/OrgUnitSelect.jsx
+++ b/src/components/orgunits/OrgUnitSelect.jsx
@@ -2,7 +2,7 @@ import { OrgUnitDimension } from '@dhis2/analytics'
import { CenteredContent, CircularLoader, Help } from '@dhis2/ui'
import cx from 'classnames'
import PropTypes from 'prop-types'
-import React, { useCallback } from 'react'
+import React, { useCallback, useMemo } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { setOrgUnits } from '../../actions/layerEdit.js'
import { translateOrgUnitLevels } from '../../util/orgUnits.js'
@@ -40,6 +40,8 @@ const OrgUnitSelect = ({
[dispatch]
)
+ const rootIds = useMemo(() => roots?.map((r) => r.id) ?? [], [roots])
+
const orgUnits = translateOrgUnitLevels(
rows?.find((r) => r.dimension === 'ou'),
levels
@@ -79,7 +81,7 @@ const OrgUnitSelect = ({
})}
>
r.id)}
+ roots={rootIds}
selected={orgUnits}
onSelect={setOrgUnitItems}
hideUserOrgUnits={hideUserOrgUnits}
diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js
index c09e25f77..71a2a1677 100644
--- a/src/constants/actionTypes.js
+++ b/src/constants/actionTypes.js
@@ -147,6 +147,8 @@ export const LAYER_EDIT_FOLLOW_UP_SET = 'LAYER_EDIT_FOLLOW_UP_SET'
export const LAYER_EDIT_NO_DATA_LEGEND_SET = 'LAYER_EDIT_NO_DATA_LEGEND_SET'
export const LAYER_EDIT_UNCLASSIFIED_LEGEND_SET =
'LAYER_EDIT_UNCLASSIFIED_LEGEND_SET'
+export const LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET =
+ 'LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET'
export const LAYER_EDIT_BAND_SET = 'LAYER_EDIT_BAND_SET'
export const LAYER_EDIT_FEATURE_STYLE_SET = 'LAYER_EDIT_FEATURE_STYLE_SET'
export const LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET =
diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js
index 32690d5dc..ad16a0564 100644
--- a/src/loaders/earthEngineLoader.js
+++ b/src/loaders/earthEngineLoader.js
@@ -4,9 +4,11 @@ import {
WARNING_NO_OU_COORD,
WARNING_NO_GEOMETRY_COORD,
ERROR_CRITICAL,
+ CUSTOM_ALERT,
} from '../constants/alerts.js'
import { getEarthEngineLayer } from '../constants/earthEngineLayers/index.js'
import { getOrgUnitsFromRows } from '../util/analytics.js'
+import { parseJsonConfig } from '../util/config.js'
import {
hasClasses,
getStaticFilterFromPeriod,
@@ -18,6 +20,7 @@ import { getRoundToPrecisionFn, formatWithSeparator } from '../util/numbers.js'
import {
getCoordinateField,
addAssociatedGeometries,
+ getOrgUnitsWithoutCoordsCount,
} from '../util/orgUnits.js'
import { GEOFEATURES_QUERY } from '../util/requests.js'
@@ -28,16 +31,74 @@ const earthEngineLoader = async ({
keyAnalysisDigitGroupSeparator,
userId,
}) => {
- const { format, rows, aggregationType } = config
+ const { format, rows, aggregationType, countFeaturesWithoutCoordinates } =
+ config
const orgUnits = getOrgUnitsFromRows(rows)
const coordinateField = getCoordinateField(config)
let loadError
const alerts = []
- let layerConfig = {}
+ // Config parsing
+ // -----
+
+ const layerConfig = parseJsonConfig(config.config)
let dataset
+
+ if (typeof config.config === 'string') {
+ // From database as favorite
+ if (layerConfig.image) {
+ // Backward compability for layers with periods saved before 2.36
+ const filter = layerConfig.filter?.[0]
+
+ if (filter) {
+ const id = filter.arguments?.[1]
+ const name = String(layerConfig.image)
+ const year =
+ typeof id === 'string' && id.length > 4
+ ? parseInt(id.substring(0, 4), 10)
+ : undefined
+
+ layerConfig.period = { id, name, year }
+
+ delete layerConfig.filter
+ }
+ delete layerConfig.image
+ } else if (layerConfig.filter) {
+ // Backward compability for layers saved before v100.6.0
+ layerConfig.period = getPeriodFromFilter(filter, layerConfig.id)
+ delete layerConfig.filter
+ }
+
+ if (layerConfig.params) {
+ // Backward compability for layers saved before v100.6.0
+ layerConfig.style = layerConfig.params
+ if (typeof layerConfig.params.palette === 'string') {
+ layerConfig.style.palette =
+ layerConfig.params.palette.split(',')
+ }
+ delete layerConfig.params
+ }
+
+ dataset = getEarthEngineLayer(layerConfig.id)
+
+ if (dataset) {
+ delete layerConfig.id
+ }
+
+ delete config.config
+ // Remove the always empty filters array from saved map layer object
+ // so as not to overwrite the filters array from the layer config
+ delete config.filters
+ } else {
+ dataset = getEarthEngineLayer(layerConfig.id)
+ }
+
+ // Data loading
+ // -----
+
let features
+ let orgUnitsWithoutCoordsCount = null
if (orgUnits && orgUnits.length) {
const orgUnitIds = orgUnits.map((item) => item.id)
@@ -57,6 +118,29 @@ const earthEngineLoader = async ({
? toGeoJson(geoFeatureData.geoFeatures)
: null
+ if (countFeaturesWithoutCoordinates) {
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine,
+ orgUnitIds,
+ userId,
+ features: mainFeatures || [],
+ })
+ if (result.error) {
+ alerts.push({
+ warning: true,
+ code: CUSTOM_ALERT,
+ message: i18n.t(
+ 'Could not count org units without coordinates'
+ ),
+ })
+ } else {
+ orgUnitsWithoutCoordsCount = result.count
+ if (result.count > 0) {
+ config.dataWithoutCoords = result.missingOrgUnits
+ }
+ }
+ }
+
if (coordinateField) {
const coordFieldData = await engine.query(GEOFEATURES_QUERY, {
variables: {
@@ -99,56 +183,8 @@ const earthEngineLoader = async ({
}
}
- if (typeof config.config === 'string') {
- // From database as favorite
- layerConfig = JSON.parse(config.config)
-
- if (layerConfig.image) {
- // Backward compability for layers with periods saved before 2.36
- const filter = layerConfig.filter?.[0]
-
- if (filter) {
- const id = filter.arguments?.[1]
- const name = String(layerConfig.image)
- const year =
- typeof id === 'string' && id.length > 4
- ? parseInt(id.substring(0, 4), 10)
- : undefined
-
- layerConfig.period = { id, name, year }
-
- delete layerConfig.filter
- }
- delete layerConfig.image
- } else if (layerConfig.filter) {
- // Backward compability for layers saved before v100.6.0
- layerConfig.period = getPeriodFromFilter(filter, layerConfig.id)
- delete layerConfig.filter
- }
-
- if (layerConfig.params) {
- // Backward compability for layers saved before v100.6.0
- layerConfig.style = layerConfig.params
- if (typeof layerConfig.params.palette === 'string') {
- layerConfig.style.palette =
- layerConfig.params.palette.split(',')
- }
- delete layerConfig.params
- }
-
- dataset = getEarthEngineLayer(layerConfig.id)
-
- if (dataset) {
- delete layerConfig.id
- }
-
- delete config.config
- // Remove the always empty filters array from saved map layer object
- // so as not to overwrite the filters array from the layer config
- delete config.filters
- } else {
- dataset = getEarthEngineLayer(layerConfig.id)
- }
+ // Legend
+ // -----
const layer = {
...dataset,
@@ -209,6 +245,10 @@ const earthEngineLoader = async ({
)
}
+ if (typeof orgUnitsWithoutCoordsCount === 'number') {
+ legend.orgUnitsWithoutCoordinatesCount = orgUnitsWithoutCoordsCount
+ }
+
const filter = getStaticFilterFromPeriod(period, filters)
return {
diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js
index 81c75b2ab..c92f8795d 100644
--- a/src/loaders/eventLoader.js
+++ b/src/loaders/eventLoader.js
@@ -25,7 +25,8 @@ import { formatStartEndDate, getDateArray } from '../util/time.js'
import { isValidUid } from '../util/uid.js'
// Server clustering if more than 2000 events
-const shouldUseServerCluster = (count) => count > EVENT_SERVER_CLUSTER_COUNT
+const shouldUseServerCluster = (count, countFeaturesWithoutCoordinates) =>
+ !countFeaturesWithoutCoordinates && count > EVENT_SERVER_CLUSTER_COUNT
const accessDeniedAlert = {
warning: true,
@@ -102,11 +103,15 @@ const loadEventLayer = async ({
loadExtended,
}) => {
const {
+ countFeaturesWithoutCoordinates,
legendDecimalPlaces,
legendIsolated,
unclassifiedLegend: unclassifiedLegendFromConfig,
noDataLegend: noDataLegendFromConfig,
} = parseJsonConfig(config.config)
+ if (countFeaturesWithoutCoordinates) {
+ config.countFeaturesWithoutCoordinates = true
+ }
if (legendDecimalPlaces !== undefined) {
config.legendDecimalPlaces = legendDecimalPlaces
}
@@ -185,13 +190,16 @@ const loadEventLayer = async ({
if (eventClustering && !styleDataItem) {
const response = await analyticsEngine.events.getCount(analyticsRequest)
config.bounds = getBounds(response.extent)
- config.serverCluster = shouldUseServerCluster(response.count)
+ config.serverCluster = shouldUseServerCluster(
+ response.count,
+ config.countFeaturesWithoutCoordinates
+ )
serverCount = response.count
}
if (!config.serverCluster) {
config.outputIdScheme = 'ID' // Required for StyleByDataItem to work
- const { data, response } = await loadData({
+ const { data, response, dataWithoutCoords } = await loadData({
request: analyticsRequest,
config,
analyticsEngine,
@@ -199,6 +207,11 @@ const loadEventLayer = async ({
const { total } = response.metaData.pager
config.data = data
+ if (config.countFeaturesWithoutCoordinates) {
+ config.dataWithoutCoords = dataWithoutCoords
+ config.legend.eventsWithoutCoordinatesCount =
+ dataWithoutCoords.length
+ }
if (styleDataItem) {
await styleByDataItem(config, engine)
diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js
index 0d47552e9..db0be8196 100644
--- a/src/loaders/facilityLoader.js
+++ b/src/loaders/facilityLoader.js
@@ -8,15 +8,44 @@ import { getOrgUnitsFromRows } from '../util/analytics.js'
import { parseJsonConfig } from '../util/config.js'
import { toGeoJson } from '../util/map.js'
import {
- ORG_UNITS_GROUP_SET_QUERY,
getPointItems,
getPolygonItems,
getStyledOrgUnits,
getCoordinateField,
- parseGroupSet,
+ getOrgUnitsWithoutCoordsCount,
+ addGroupCountsToLegend,
+ loadGroupSetData,
+ fetchAssociatedGeometries,
} from '../util/orgUnits.js'
import { GEOFEATURES_QUERY } from '../util/requests.js'
+const applyMissingCoordsCount = async (
+ config,
+ { engine, orgUnitIds, userId, features, legend, alerts }
+) => {
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine,
+ orgUnitIds,
+ userId,
+ features,
+ })
+ if (result.error) {
+ alerts.push({
+ warning: true,
+ code: CUSTOM_ALERT,
+ message: i18n.t(
+ 'Could not count org units without a point location'
+ ),
+ })
+ return
+ }
+ legend.orgUnitsWithoutCoordinatesCount = result.count
+ legend.orgUnitsPointOnly = true
+ if (result.count > 0) {
+ config.dataWithoutCoords = result.missingOrgUnits
+ }
+}
+
const facilityLoader = async ({
config,
engine,
@@ -25,48 +54,40 @@ const facilityLoader = async ({
baseUrl,
}) => {
const { rows, organisationUnitGroupSet: groupSet, areaRadius } = config
-
- const orgUnits = getOrgUnitsFromRows(rows)
const includeGroupSets = !!groupSet
+ const orgUnits = getOrgUnitsFromRows(rows)
+ const orgUnitIds = orgUnits.map((item) => item.id)
const coordinateField = getCoordinateField(config)
- let loadError
+ const name = i18n.t('Facilities')
const alerts = []
- const { unclassifiedLegend } = parseJsonConfig(config.config)
+ // Config parsing
+ // -----
+
+ const { countFeaturesWithoutCoordinates, unclassifiedLegend } =
+ parseJsonConfig(config.config)
+ if (countFeaturesWithoutCoordinates) {
+ config.countFeaturesWithoutCoordinates = true
+ }
if (unclassifiedLegend) {
config.unclassifiedLegend = unclassifiedLegend
}
delete config.config
- const orgUnitIds = orgUnits.map((item) => item.id)
- let associatedGeometries
-
- const name = i18n.t('Facilities')
+ // Data loading
+ // -----
- let data = {}
+ let data
try {
- // Fetch geofeatures data
- data = await engine.query(
- GEOFEATURES_QUERY,
- {
- variables: {
- orgUnitIds,
- keyAnalysisDisplayProperty,
- includeGroupSets,
- userId,
- },
+ data = await engine.query(GEOFEATURES_QUERY, {
+ variables: {
+ orgUnitIds,
+ keyAnalysisDisplayProperty,
+ includeGroupSets,
+ userId,
},
- {
- onError: (error) => {
- alerts.push({
- critical: true,
- code: ERROR_CRITICAL,
- message: error.message || i18n.t('an error occurred'),
- })
- },
- }
- )
+ })
} catch (error) {
alerts.push({
critical: true,
@@ -75,61 +96,64 @@ const facilityLoader = async ({
})
}
- const features =
- data?.geoFeatures && toGeoJson(getPointItems(data.geoFeatures))
-
- // Load organisationUnitGroups if not passed
- let orgUnitGroups
- if (includeGroupSets && !groupSet.organisationUnitGroups) {
- try {
- orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, {
- variables: {
- id: groupSet?.id,
- },
- })
- } catch (err) {
- loadError = i18n.t('GroupSet used for styling was not found')
- }
- }
+ const features = data?.geoFeatures
+ ? toGeoJson(getPointItems(data.geoFeatures))
+ : []
- if (orgUnitGroups) {
- const { groupSets } = orgUnitGroups
- groupSet.organisationUnitGroups = parseGroupSet({
- organisationUnitGroups: groupSets.organisationUnitGroups,
- })
- groupSet.name = groupSets.name
- }
-
- const { styledFeatures, legend } = getStyledOrgUnits({
- features,
- groupSet,
- config,
- baseUrl,
- })
- legend.title = name
+ const loadError = await loadGroupSetData(engine, groupSet, includeGroupSets)
+ let associatedGeometries
if (coordinateField) {
- const rawData = await engine.query(GEOFEATURES_QUERY, {
- variables: {
+ associatedGeometries = await fetchAssociatedGeometries(
+ engine,
+ {
orgUnitIds,
keyAnalysisDisplayProperty,
includeGroupSets,
- coordinateField: coordinateField.id,
+ coordinateField,
userId,
},
- })
-
- associatedGeometries = rawData?.geoFeatures
- ? toGeoJson(getPolygonItems(rawData.geoFeatures))
- : null
+ getPolygonItems
+ )
- if (!associatedGeometries.length) {
+ if (!associatedGeometries?.length) {
alerts.push({
code: WARNING_NO_GEOMETRY_COORD,
message: coordinateField.name,
})
}
+ }
+ // Styling and Legend
+ // -----
+
+ const { styledFeatures, legend } = getStyledOrgUnits({
+ features,
+ groupSet,
+ config,
+ baseUrl,
+ })
+
+ legend.title = name
+
+ if (groupSet?.id) {
+ addGroupCountsToLegend(legend.items, styledFeatures, groupSet)
+ } else if (legend.items[0]) {
+ legend.items[0].count = styledFeatures.length
+ }
+
+ if (config.countFeaturesWithoutCoordinates) {
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds,
+ userId,
+ features,
+ legend,
+ alerts,
+ })
+ }
+
+ if (coordinateField) {
legend.items.push({
name: coordinateField.name,
type: 'polygon',
@@ -147,7 +171,7 @@ const facilityLoader = async ({
alerts.push({
warning: true,
code: CUSTOM_ALERT,
- message: i18n.t('No coordinates found for selected facilities'),
+ message: i18n.t('No point locations found for selected facilities'),
})
}
@@ -161,6 +185,7 @@ const facilityLoader = async ({
isLoaded: true,
isLoading: false,
isExpanded: true,
+ isVisible: config.isVisible ?? true,
loadError,
}
}
diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js
index 38b2e9b9d..64f2711f3 100644
--- a/src/loaders/orgUnitLoader.js
+++ b/src/loaders/orgUnitLoader.js
@@ -4,19 +4,59 @@ import {
WARNING_NO_OU_COORD,
WARNING_NO_GEOMETRY_COORD,
ERROR_CRITICAL,
+ CUSTOM_ALERT,
} from '../constants/alerts.js'
import { getOrgUnitsFromRows } from '../util/analytics.js'
import { parseJsonConfig } from '../util/config.js'
import { toGeoJson } from '../util/map.js'
import {
- ORG_UNITS_GROUP_SET_QUERY,
addAssociatedGeometries,
getStyledOrgUnits,
getCoordinateField,
- parseGroupSet,
+ getOrgUnitsWithoutCoordsCount,
+ fetchOrgUnitDetails,
+ addGroupCountsToLegend,
+ addLevelCountsToLegend,
+ loadGroupSetData,
+ fetchAssociatedGeometries,
} from '../util/orgUnits.js'
import { GEOFEATURES_QUERY } from '../util/requests.js'
+const applyMissingCoordsCount = async (
+ config,
+ { engine, orgUnitIds, userId, features, legend, alerts }
+) => {
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine,
+ orgUnitIds,
+ userId,
+ features,
+ })
+ if (result.error) {
+ alerts.push({
+ warning: true,
+ code: CUSTOM_ALERT,
+ message: i18n.t('Could not count org units without coordinates'),
+ })
+ return
+ }
+ legend.orgUnitsWithoutCoordinatesCount = result.count
+ if (result.count > 0) {
+ const details = await fetchOrgUnitDetails(
+ engine,
+ result.missingOrgUnits.map((o) => o.id)
+ )
+ config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({
+ ...ou,
+ properties: {
+ ...ou.properties,
+ level: details[ou.id]?.level,
+ parentName: details[ou.id]?.parentName,
+ },
+ }))
+ }
+}
+
const orgUnitLoader = async ({
config,
engine,
@@ -25,23 +65,29 @@ const orgUnitLoader = async ({
baseUrl,
}) => {
const { rows, organisationUnitGroupSet: groupSet } = config
-
- const orgUnits = getOrgUnitsFromRows(rows)
const includeGroupSets = !!groupSet
+ const orgUnits = getOrgUnitsFromRows(rows)
+ const orgUnitIds = orgUnits.map((item) => item.id)
const coordinateField = getCoordinateField(config)
- let loadError
+ const name = i18n.t('Organisation units')
const alerts = []
- const { unclassifiedLegend } = parseJsonConfig(config.config)
+ // Config parsing
+ // -----
+
+ const { countFeaturesWithoutCoordinates, unclassifiedLegend } =
+ parseJsonConfig(config.config)
+ if (countFeaturesWithoutCoordinates) {
+ config.countFeaturesWithoutCoordinates = true
+ }
if (unclassifiedLegend) {
config.unclassifiedLegend = unclassifiedLegend
}
delete config.config
- const orgUnitIds = orgUnits.map((item) => item.id)
- let associatedGeometries
- const name = i18n.t('Organisation units')
+ // Data loading
+ // -----
const data = await engine.query(
GEOFEATURES_QUERY,
@@ -65,23 +111,6 @@ const orgUnitLoader = async ({
)
const mainFeatures = data?.geoFeatures ? toGeoJson(data.geoFeatures) : []
-
- const orgUnitLevels = await apiFetchOrganisationUnitLevels(engine)
-
- // Load organisationUnitGroups if not passed
- let orgUnitGroups
- if (includeGroupSets && !groupSet.organisationUnitGroups) {
- try {
- orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, {
- variables: {
- id: groupSet?.id,
- },
- })
- } catch (err) {
- loadError = i18n.t('GroupSet used for styling was not found')
- }
- }
-
if (!mainFeatures.length && !alerts.length) {
alerts.push({
code: WARNING_NO_OU_COORD,
@@ -89,30 +118,21 @@ const orgUnitLoader = async ({
})
}
- if (orgUnitGroups) {
- const { groupSets } = orgUnitGroups
- groupSet.organisationUnitGroups = parseGroupSet({
- organisationUnitGroups: groupSets.organisationUnitGroups,
- })
- groupSet.name = groupSets.name
- }
+ const levels = await apiFetchOrganisationUnitLevels(engine)
+ const loadError = await loadGroupSetData(engine, groupSet, includeGroupSets)
+
+ let associatedGeometries
if (coordinateField) {
- const rawData = await engine.query(GEOFEATURES_QUERY, {
- variables: {
- orgUnitIds,
- keyAnalysisDisplayProperty,
- includeGroupSets,
- coordinateField: coordinateField.id,
- userId,
- },
+ associatedGeometries = await fetchAssociatedGeometries(engine, {
+ orgUnitIds,
+ keyAnalysisDisplayProperty,
+ includeGroupSets,
+ coordinateField,
+ userId,
})
- associatedGeometries = rawData?.geoFeatures
- ? toGeoJson(rawData.geoFeatures)
- : null
-
- if (!associatedGeometries.length) {
+ if (!associatedGeometries?.length) {
alerts.push({
code: WARNING_NO_GEOMETRY_COORD,
message: coordinateField.name,
@@ -122,22 +142,38 @@ const orgUnitLoader = async ({
const features = addAssociatedGeometries(mainFeatures, associatedGeometries)
+ // Styling and Legend
+ // -----
+
const { styledFeatures, legend } = getStyledOrgUnits({
features,
groupSet,
config,
baseUrl,
- orgUnitLevels: orgUnitLevels.reduce(
- (obj, item) => ({
- ...obj,
- [item.level]: item.displayName, // orgUnitLevels do not have shortNames
- }),
- {}
+ orgUnitLevels: Object.fromEntries(
+ levels.map(({ level, displayName }) => [level, displayName])
),
})
legend.title = name
+ if (groupSet?.id) {
+ addGroupCountsToLegend(legend.items, mainFeatures, groupSet)
+ } else {
+ addLevelCountsToLegend(legend.items, mainFeatures, levels)
+ }
+
+ if (config.countFeaturesWithoutCoordinates) {
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds,
+ userId,
+ features: mainFeatures,
+ legend,
+ alerts,
+ })
+ }
+
return {
...config,
data: styledFeatures,
@@ -147,6 +183,7 @@ const orgUnitLoader = async ({
isLoaded: true,
isLoading: false,
isExpanded: true,
+ isVisible: config.isVisible ?? true,
loadError,
}
}
diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js
index 1751fa31e..b9017113b 100644
--- a/src/loaders/thematicLoader.js
+++ b/src/loaders/thematicLoader.js
@@ -6,6 +6,7 @@ import {
WARNING_NO_OU_COORD,
WARNING_NO_GEOMETRY_COORD,
ERROR_CRITICAL,
+ CUSTOM_ALERT,
} from '../constants/alerts.js'
import { dimConf } from '../constants/dimension.js'
import { EVENT_STATUS_COMPLETED } from '../constants/eventStatuses.js'
@@ -46,6 +47,8 @@ import {
import {
getCoordinateField,
addAssociatedGeometries,
+ getOrgUnitsWithoutCoordsCount,
+ fetchOrgUnitDetails,
} from '../util/orgUnits.js'
import { LEGEND_SET_QUERY, GEOFEATURES_QUERY } from '../util/requests.js'
import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js'
@@ -76,11 +79,15 @@ const thematicLoader = async ({
const coordinateField = getCoordinateField(config)
const {
+ countFeaturesWithoutCoordinates,
legendDecimalPlaces,
legendIsolated,
unclassifiedLegend: unclassifiedLegendFromConfig,
noDataLegend: noDataLegendFromConfig,
} = parseJsonConfig(config.config)
+ if (countFeaturesWithoutCoordinates) {
+ config.countFeaturesWithoutCoordinates = true
+ }
if (legendDecimalPlaces !== undefined) {
config.legendDecimalPlaces = legendDecimalPlaces
}
@@ -102,6 +109,9 @@ const thematicLoader = async ({
delete config.noDataColor
delete config.config
+ const orgUnitIds = getOrgUnitsFromRows(config.rows).map((item) => item.id)
+ let orgUnitsWithoutCoordsCount = null
+
// Resolve legendSet and method (favorites may have the wrong method)
const legendSet = await resolveLegendSet(config, dataItem, engine)
const method = legendSet ? CLASSIFICATION_PREDEFINED : config.method
@@ -172,6 +182,40 @@ const thematicLoader = async ({
const [mainFeatures, data, associatedGeometries] = response
const valueById = getValueById(data)
+
+ if (config.countFeaturesWithoutCoordinates) {
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine,
+ orgUnitIds,
+ userId,
+ features: mainFeatures || [],
+ })
+ if (!result.error) {
+ orgUnitsWithoutCoordsCount = result.count
+ if (result.count > 0) {
+ const details = await fetchOrgUnitDetails(
+ engine,
+ result.missingOrgUnits.map((o) => o.id)
+ )
+ config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({
+ ...ou,
+ properties: {
+ ...ou.properties,
+ level: details[ou.id]?.level,
+ parentName: details[ou.id]?.parentName,
+ rawValue: valueById[ou.id],
+ value:
+ valueById[ou.id] === undefined
+ ? undefined
+ : formatWithSeparator(
+ valueById[ou.id],
+ keyAnalysisDigitGroupSeparator
+ ),
+ },
+ }))
+ }
+ }
+ }
const valuesByPeriod = isSingleMap ? null : getValuesByPeriod(data) // [PATH] null → Single; populated → Timeline / Split (do not creates OrgUnits with no data)
const names = getApiResponseNames(
@@ -220,6 +264,17 @@ const thematicLoader = async ({
})
}
+ if (
+ config.countFeaturesWithoutCoordinates &&
+ typeof orgUnitsWithoutCoordsCount !== 'number'
+ ) {
+ alerts.push({
+ warning: true,
+ code: CUSTOM_ALERT,
+ message: i18n.t('Could not count org units without coordinates'),
+ })
+ }
+
if (coordinateField && !associatedGeometries.length) {
alerts.push({
code: WARNING_NO_GEOMETRY_COORD,
@@ -271,6 +326,10 @@ const thematicLoader = async ({
decimalPlaces: config.legendDecimalPlaces,
}
+ if (typeof orgUnitsWithoutCoordsCount === 'number') {
+ legend.orgUnitsWithoutCoordinatesCount = orgUnitsWithoutCoordsCount
+ }
+
if (dimensions && dimensions.length) {
legend.filters = dimensions.map(
(d) =>
diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js
index 2d1d72cd7..904b1c8c1 100644
--- a/src/reducers/layerEdit.js
+++ b/src/reducers/layerEdit.js
@@ -370,6 +370,12 @@ const layerEdit = (state = null, action) => {
eventClustering: action.checked,
}
+ case types.LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET:
+ return {
+ ...state,
+ countFeaturesWithoutCoordinates: action.checked,
+ }
+
case types.LAYER_EDIT_EVENT_POINT_RADIUS_SET:
return {
...state,
diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js
index 2445b4579..5189315bc 100644
--- a/src/util/__tests__/favorites.spec.js
+++ b/src/util/__tests__/favorites.spec.js
@@ -734,4 +734,68 @@ describe('cleanMapConfig', () => {
],
})
})
+
+ test('serializes countFeaturesWithoutCoordinates into config JSON for thematic layer', () => {
+ const config = {
+ mapViews: [
+ {
+ layer: 'thematic',
+ name: 'My Thematic',
+ rows: [],
+ countFeaturesWithoutCoordinates: true,
+ },
+ ],
+ }
+ const cleanedConfig = cleanMapConfig({
+ config,
+ defaultBasemapId: 'default',
+ })
+ const mapView = cleanedConfig.mapViews[0]
+ const parsedConfig = JSON.parse(mapView.config)
+ expect(parsedConfig.countFeaturesWithoutCoordinates).toBe(true)
+ expect(mapView.countFeaturesWithoutCoordinates).toBeUndefined()
+ })
+
+ test('serializes countFeaturesWithoutCoordinates into config JSON for event layer', () => {
+ const config = {
+ mapViews: [
+ {
+ layer: 'event',
+ name: 'My Events',
+ rows: [],
+ countFeaturesWithoutCoordinates: true,
+ },
+ ],
+ }
+ const cleanedConfig = cleanMapConfig({
+ config,
+ defaultBasemapId: 'default',
+ })
+ const mapView = cleanedConfig.mapViews[0]
+ const parsedConfig = JSON.parse(mapView.config)
+ expect(parsedConfig.countFeaturesWithoutCoordinates).toBe(true)
+ expect(mapView.countFeaturesWithoutCoordinates).toBeUndefined()
+ })
+
+ test('preserves countFeaturesWithoutCoordinates as top-level property for earth engine layer', () => {
+ const config = {
+ mapViews: [
+ {
+ layer: 'earthEngine',
+ layerId: 'NASA/FIRMS',
+ name: 'Fire',
+ rows: [],
+ countFeaturesWithoutCoordinates: true,
+ },
+ ],
+ }
+ const cleanedConfig = cleanMapConfig({
+ config,
+ defaultBasemapId: 'default',
+ })
+ const mapView = cleanedConfig.mapViews[0]
+ expect(mapView.countFeaturesWithoutCoordinates).toBe(true)
+ const parsedConfig = JSON.parse(mapView.config)
+ expect(parsedConfig.countFeaturesWithoutCoordinates).toBeUndefined()
+ })
})
diff --git a/src/util/__tests__/geojson.spec.js b/src/util/__tests__/geojson.spec.js
index 8cf0e92c8..7d4f1b814 100644
--- a/src/util/__tests__/geojson.spec.js
+++ b/src/util/__tests__/geojson.spec.js
@@ -103,6 +103,32 @@ describe('geojson utils', () => {
const getter = buildEventGeometryGetter(headers)
expect(getter(dummyEvent)).toEqual(point)
})
+
+ it('Should return null when geometry value is null', () => {
+ const getter = buildEventGeometryGetter(headers)
+ const eventWithNoGeom = [
+ 'MyID',
+ null,
+ JSON.stringify(stringCoords),
+ 54321,
+ arrayCoords,
+ 1234,
+ ]
+ expect(getter(eventWithNoGeom)).toBeNull()
+ })
+
+ it('Should return null when geometry value is empty string', () => {
+ const getter = buildEventGeometryGetter(headers)
+ const eventWithEmptyGeom = [
+ 'MyID',
+ '',
+ JSON.stringify(stringCoords),
+ 54321,
+ arrayCoords,
+ 1234,
+ ]
+ expect(getter(eventWithEmptyGeom)).toBeNull()
+ })
})
describe('createEventFeatures', () => {
@@ -235,6 +261,25 @@ describe('geojson utils', () => {
}))
)
})
+
+ it('Should split rows with null geometry into dataWithoutCoords', () => {
+ const noGeomRow = ['val', 'psi_nogeom', 'id_nogeom', null, 'other']
+ const responseWithMissing = {
+ headers,
+ rows: [...rows, noGeomRow],
+ metaData,
+ }
+ const out = createEventFeatures(responseWithMissing)
+ expect(out.data).toHaveLength(rows.length)
+ expect(out.dataWithoutCoords).toHaveLength(1)
+ expect(out.dataWithoutCoords[0].id).toBe('psi_nogeom')
+ expect(out.dataWithoutCoords[0].geometry).toBeNull()
+ })
+
+ it('Should return empty dataWithoutCoords when all rows have geometry', () => {
+ const out = createEventFeatures(response)
+ expect(out.dataWithoutCoords).toHaveLength(0)
+ })
})
describe('addStyleDataItem', () => {
diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js
index 4ac5e9639..fe3c12dad 100644
--- a/src/util/__tests__/orgUnits.spec.js
+++ b/src/util/__tests__/orgUnits.spec.js
@@ -1,4 +1,11 @@
-import { getStyledOrgUnits } from '../orgUnits.js'
+import {
+ getStyledOrgUnits,
+ addGroupCountsToLegend,
+ addLevelCountsToLegend,
+ getOrgUnitsWithoutCoordsCount,
+ fetchAndParseGroupSet,
+ loadGroupSetData,
+} from '../orgUnits.js'
describe('getStyledOrgUnits', () => {
it('should return styled features and legend for facility layer', () => {
@@ -39,6 +46,23 @@ describe('getStyledOrgUnits', () => {
expect(result.legend.unit).toEqual('GroupSet1')
})
+ it('should return an empty facility item with count when no group set is selected', () => {
+ const features = [
+ {
+ geometry: { type: 'Point' },
+ properties: { dimensions: {} },
+ },
+ ]
+ const result = getStyledOrgUnits({
+ features,
+ groupSet: {},
+ config: {},
+ baseUrl: '/baseUrl',
+ })
+ expect(result.legend.items).toHaveLength(1)
+ expect(result.legend.items[0]).toMatchObject({ name: 'Facility' })
+ })
+
it('should return styled features and legend for orgUnit layer', () => {
const features = [
{
@@ -273,3 +297,234 @@ describe('getStyledOrgUnits', () => {
)
})
})
+
+describe('addGroupCountsToLegend', () => {
+ it('should initialize all item counts to 0', () => {
+ const legendItems = [
+ { id: 'g1', count: 5 },
+ { id: 'g2', count: 3 },
+ ]
+ addGroupCountsToLegend(legendItems, [], { id: 'gs1' })
+ expect(legendItems[0].count).toBe(0)
+ expect(legendItems[1].count).toBe(0)
+ })
+
+ it('should increment count for features matching a group', () => {
+ const legendItems = [
+ { id: 'g1', count: 0 },
+ { id: 'g2', count: 0 },
+ ]
+ const features = [
+ { properties: { dimensions: { gs1: 'g1' } } },
+ { properties: { dimensions: { gs1: 'g1' } } },
+ { properties: { dimensions: { gs1: 'g2' } } },
+ ]
+ addGroupCountsToLegend(legendItems, features, { id: 'gs1' })
+ expect(legendItems[0].count).toBe(2)
+ expect(legendItems[1].count).toBe(1)
+ })
+
+ it('should not increment count for features with no matching group', () => {
+ const legendItems = [{ id: 'g1', count: 0 }]
+ const features = [{ properties: { dimensions: { gs1: 'g99' } } }]
+ addGroupCountsToLegend(legendItems, features, { id: 'gs1' })
+ expect(legendItems[0].count).toBe(0)
+ })
+
+ it('should handle features with missing dimensions gracefully', () => {
+ const legendItems = [{ id: 'g1', count: 0 }]
+ const features = [
+ { properties: {} },
+ { properties: { dimensions: {} } },
+ ]
+ addGroupCountsToLegend(legendItems, features, { id: 'gs1' })
+ expect(legendItems[0].count).toBe(0)
+ })
+})
+
+describe('getOrgUnitsWithoutCoordsCount', () => {
+ it('should return zero count immediately when orgUnitIds is empty', async () => {
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine: {},
+ orgUnitIds: [],
+ userId: 'user1',
+ features: [],
+ })
+ expect(result).toEqual({ count: 0, missingOrgUnits: [] })
+ })
+
+ it('should return error when no data element is found', async () => {
+ const engine = {
+ query: jest
+ .fn()
+ .mockResolvedValue({ dataElements: { dataElements: [] } }),
+ }
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine,
+ orgUnitIds: ['ou1'],
+ userId: 'user1',
+ features: [],
+ })
+ expect(result).toEqual({ error: true })
+ })
+
+ it('should count org units present in analytics but absent from features', async () => {
+ const engine = {
+ query: jest
+ .fn()
+ .mockResolvedValueOnce({
+ dataElements: { dataElements: [{ id: 'de1' }] },
+ })
+ .mockResolvedValueOnce({
+ orgUnitsCount: {
+ metaData: {
+ dimensions: { ou: ['ou1', 'ou2', 'ou3'] },
+ items: {
+ ou1: { name: 'OU 1' },
+ ou2: { name: 'OU 2' },
+ ou3: { name: 'OU 3' },
+ },
+ },
+ },
+ }),
+ }
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine,
+ orgUnitIds: ['ou1', 'ou2', 'ou3'],
+ userId: 'user1',
+ features: [{ id: 'ou1' }],
+ })
+ expect(result.count).toBe(2)
+ expect(result.missingOrgUnits).toHaveLength(2)
+ expect(result.missingOrgUnits.map((o) => o.id)).toEqual(['ou2', 'ou3'])
+ })
+
+ it('should return error when engine query throws', async () => {
+ const engine = {
+ query: jest.fn().mockRejectedValue(new Error('Network error')),
+ }
+ const result = await getOrgUnitsWithoutCoordsCount({
+ engine,
+ orgUnitIds: ['ou1'],
+ userId: 'user1',
+ features: [],
+ })
+ expect(result).toEqual({ error: true })
+ })
+})
+
+describe('fetchAndParseGroupSet', () => {
+ it('should return parsed group set on success', async () => {
+ const engine = {
+ query: jest.fn().mockResolvedValue({
+ groupSets: {
+ name: 'My Group Set',
+ organisationUnitGroups: [
+ {
+ id: 'g1',
+ name: 'Group 1',
+ color: '#ff0000',
+ symbol: '01.png',
+ },
+ ],
+ },
+ }),
+ }
+ const result = await fetchAndParseGroupSet(engine, { id: 'gs1' })
+ expect(result).not.toBeNull()
+ expect(result.name).toBe('My Group Set')
+ expect(result.organisationUnitGroups).toHaveLength(1)
+ expect(result.organisationUnitGroups[0].id).toBe('g1')
+ })
+
+ it('should return null when query fails', async () => {
+ const engine = {
+ query: jest.fn().mockRejectedValue(new Error('Not found')),
+ }
+ const result = await fetchAndParseGroupSet(engine, { id: 'gs1' })
+ expect(result).toBeNull()
+ })
+})
+
+describe('addLevelCountsToLegend', () => {
+ const levels = [
+ { level: 1, displayName: 'National' },
+ { level: 2, displayName: 'District' },
+ ]
+
+ it('should initialize all item counts to 0', () => {
+ const legendItems = [
+ { name: 'National', count: 5 },
+ { name: 'District', count: 3 },
+ ]
+ addLevelCountsToLegend(legendItems, [], levels)
+ expect(legendItems[0].count).toBe(0)
+ expect(legendItems[1].count).toBe(0)
+ })
+
+ it('should increment count for features matching a level', () => {
+ const legendItems = [
+ { name: 'National', count: 0 },
+ { name: 'District', count: 0 },
+ ]
+ const features = [
+ { properties: { level: 1 } },
+ { properties: { level: 2 } },
+ { properties: { level: 2 } },
+ ]
+ addLevelCountsToLegend(legendItems, features, levels)
+ expect(legendItems[0].count).toBe(1)
+ expect(legendItems[1].count).toBe(2)
+ })
+
+ it('should not increment count for features with no matching level', () => {
+ const legendItems = [{ name: 'National', count: 0 }]
+ const features = [{ properties: { level: 99 } }]
+ addLevelCountsToLegend(legendItems, features, levels)
+ expect(legendItems[0].count).toBe(0)
+ })
+})
+
+describe('loadGroupSetData', () => {
+ it('should return null when includeGroupSets is false', async () => {
+ const engine = { query: jest.fn() }
+ const result = await loadGroupSetData(engine, {}, false)
+ expect(result).toBeNull()
+ expect(engine.query).not.toHaveBeenCalled()
+ })
+
+ it('should return null when groupSet already has organisationUnitGroups', async () => {
+ const engine = { query: jest.fn() }
+ const groupSet = { id: 'gs1', organisationUnitGroups: [] }
+ const result = await loadGroupSetData(engine, groupSet, true)
+ expect(result).toBeNull()
+ expect(engine.query).not.toHaveBeenCalled()
+ })
+
+ it('should mutate groupSet and return null on success', async () => {
+ const engine = {
+ query: jest.fn().mockResolvedValue({
+ groupSets: {
+ name: 'My Group Set',
+ organisationUnitGroups: [
+ { id: 'g1', name: 'Group 1', color: '#ff0000' },
+ ],
+ },
+ }),
+ }
+ const groupSet = { id: 'gs1' }
+ const result = await loadGroupSetData(engine, groupSet, true)
+ expect(result).toBeNull()
+ expect(groupSet.name).toBe('My Group Set')
+ expect(groupSet.organisationUnitGroups).toHaveLength(1)
+ })
+
+ it('should return an error string when query fails', async () => {
+ const engine = {
+ query: jest.fn().mockRejectedValue(new Error('Not found')),
+ }
+ const groupSet = { id: 'gs1' }
+ const result = await loadGroupSetData(engine, groupSet, true)
+ expect(typeof result).toBe('string')
+ })
+})
diff --git a/src/util/event.js b/src/util/event.js
index 52366960c..222aa5628 100644
--- a/src/util/event.js
+++ b/src/util/event.js
@@ -72,6 +72,7 @@ export const getAnalyticsRequest = async (
fallbackCoordinateField,
relativePeriodDate,
isExtended,
+ countFeaturesWithoutCoordinates,
},
{ nameProperty, engine, analyticsEngine }
) => {
@@ -99,7 +100,7 @@ export const getAnalyticsRequest = async (
let analyticsRequest = new analyticsEngine.request()
.withProgram(program.id)
.withStage(programStage.id)
- .withCoordinatesOnly(true)
+ .withCoordinatesOnly(!countFeaturesWithoutCoordinates)
analyticsRequest = period
? analyticsRequest.addPeriodFilter(period.id)
@@ -157,11 +158,15 @@ export const loadData = async ({
request.withPageSize(pageSize)
) // DHIS2-10742
- const { data, names } = createEventFeatures(response, config)
+ const { data, names, dataWithoutCoords } = createEventFeatures(
+ response,
+ config
+ )
return {
data,
names,
+ dataWithoutCoords,
response,
}
}
diff --git a/src/util/favorites.js b/src/util/favorites.js
index eee0b01f6..1e90f4a73 100644
--- a/src/util/favorites.js
+++ b/src/util/favorites.js
@@ -56,6 +56,7 @@ const validLayerProperties = [
'labelFontWeight',
'labelFontColor',
'labelTemplate',
+ 'countFeaturesWithoutCoordinates',
'legendDecimalPlaces',
'legendIsolated',
'lastUpdated',
@@ -222,8 +223,8 @@ const models2objects = (layer, cleanMapviewConfig) => {
} else if (
layerType === THEMATIC_LAYER ||
layerType === EVENT_LAYER ||
- layerType === FACILITY_LAYER ||
- layerType === ORG_UNIT_LAYER
+ layerType === ORG_UNIT_LAYER ||
+ layerType === FACILITY_LAYER
) {
if (cleanMapviewConfig) {
const configData = {}
@@ -240,6 +241,9 @@ const models2objects = (layer, cleanMapviewConfig) => {
layer.noDataColor = layer.noDataLegend.color // noDataColor is the DHIS2 API schema field — store color there for backward compatibility
configData.noDataLegend = layer.noDataLegend
}
+ if (layer.countFeaturesWithoutCoordinates) {
+ configData.countFeaturesWithoutCoordinates = true
+ }
if (Object.keys(configData).length) {
layer.config = JSON.stringify(configData)
}
@@ -249,6 +253,7 @@ const models2objects = (layer, cleanMapviewConfig) => {
delete layer.legendIsolated
delete layer.noDataLegend
delete layer.unclassifiedLegend
+ delete layer.countFeaturesWithoutCoordinates
} else if (layerType === GEOJSON_URL_LAYER) {
if (cleanMapviewConfig) {
layer.config = {
diff --git a/src/util/geojson.js b/src/util/geojson.js
index c5f18ba41..5f1e78168 100644
--- a/src/util/geojson.js
+++ b/src/util/geojson.js
@@ -65,7 +65,10 @@ export const createEventFeature = ({
export const buildEventGeometryGetter = (headers) => {
const geomCol = findIndex(headers, (h) => h.name === 'geometry')
- return (event) => JSON.parse(event[geomCol])
+ return (event) => {
+ const value = event[geomCol]
+ return value ? JSON.parse(value) : null
+ }
}
export const createEventFeatures = (response, config = {}) => {
@@ -88,8 +91,11 @@ export const createEventFeatures = (response, config = {}) => {
)
const options = Object.values(response.metaData.items)
- const data = response.rows.map((row) =>
- createEventFeature({
+ const data = []
+ const dataWithoutCoords = []
+
+ response.rows.forEach((row) => {
+ const feature = createEventFeature({
headers: response.headers,
names: config.outputIdScheme !== 'ID' ? names : {},
options,
@@ -98,7 +104,12 @@ export const createEventFeatures = (response, config = {}) => {
getGeometry,
geometryCentroid: config.geometryCentroid,
})
- )
+ if (feature.geometry) {
+ data.push(feature)
+ } else {
+ dataWithoutCoords.push(feature)
+ }
+ })
// Sort to draw polygons before points
data.sort((feature) =>
@@ -109,7 +120,7 @@ export const createEventFeatures = (response, config = {}) => {
: 0
)
- return { data, names }
+ return { data, names, dataWithoutCoords }
}
// Include column for data element used for styling (if not already used in filter)
@@ -241,7 +252,7 @@ export const buildGeoJsonFeatures = (geoJson) => {
const types = []
const featureCollection = finalGeoJson.features.map((f, i) => {
- const nonMultiType = f.geometry.type.replace('Multi', '')
+ const nonMultiType = f.geometry.type.replaceAll('Multi', '')
// return list of types in the data (but not Multi* types,
// because those should get lumped in with the non-Multi* type for the legend)
if (!types.includes(nonMultiType)) {
diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js
index 6a1015e1c..1d89a4f79 100644
--- a/src/util/orgUnits.js
+++ b/src/util/orgUnits.js
@@ -10,6 +10,13 @@ import {
NONE,
} from '../constants/layers.js'
import { getUniqueColor } from './colors.js'
+import { toGeoJson } from './map.js'
+import {
+ FIRST_DATA_ELEMENT_QUERY,
+ GEOFEATURES_QUERY,
+ ORG_UNITS_COUNT_QUERY,
+ ORG_UNIT_DETAILS_QUERY,
+} from './requests.js'
const getGroupColor = (groups) => {
const groupsWithoutColors = groups.filter((g) => !g.color)
@@ -81,13 +88,15 @@ export const getOrgUnitGroupLegendItems = (
useColor,
contextPath
) =>
- groups.map(({ name, color = true, symbol }) =>
+ groups.map(({ id, name, color = true, symbol }) =>
useColor
? {
+ id,
name,
color,
}
: {
+ id,
name,
image: `${contextPath}/images/orgunitgroup/${symbol}`,
}
@@ -205,7 +214,9 @@ export const getStyledOrgUnits = ({
styledFeatures,
legend: {
unit: name,
- items: [...levelItems, ...groupItems, ...facilityItems],
+ items: groupItems.length
+ ? groupItems
+ : [...levelItems, ...facilityItems],
},
}
}
@@ -238,6 +249,160 @@ export const getCoordinateField = ({ orgUnitField, orgUnitFieldDisplayName }) =>
? { id: orgUnitField, name: orgUnitFieldDisplayName }
: null
+export const getOrgUnitsWithoutCoordsCount = async ({
+ engine,
+ orgUnitIds,
+ userId,
+ features = [],
+}) => {
+ if (!orgUnitIds.length) {
+ return { count: 0, missingOrgUnits: [] }
+ }
+
+ try {
+ const deResult = await engine.query(FIRST_DATA_ELEMENT_QUERY)
+ const dataElementId = deResult?.dataElements?.dataElements?.[0]?.id
+ if (!dataElementId) {
+ return { error: true }
+ }
+
+ const countData = await engine.query(ORG_UNITS_COUNT_QUERY, {
+ variables: { dataElementId, orgUnitIds, userId },
+ })
+ const allOuIds =
+ countData?.orgUnitsCount?.metaData?.dimensions?.ou || []
+ const metaItems = countData?.orgUnitsCount?.metaData?.items || {}
+
+ const featureIds = new Set(features.map((f) => f.id))
+ const missingOrgUnits = allOuIds
+ .filter((id) => !featureIds.has(id))
+ .map((id) => ({
+ id,
+ properties: { id, name: metaItems[id]?.name || id },
+ }))
+
+ return { count: missingOrgUnits.length, missingOrgUnits }
+ } catch {
+ return { error: true }
+ }
+}
+
+const OU_DETAILS_BATCH_SIZE = 100
+
+export const fetchOrgUnitDetails = async (engine, ids) => {
+ if (!ids.length) {
+ return {}
+ }
+
+ const batches = []
+ for (let i = 0; i < ids.length; i += OU_DETAILS_BATCH_SIZE) {
+ batches.push(ids.slice(i, i + OU_DETAILS_BATCH_SIZE))
+ }
+
+ try {
+ const results = await Promise.all(
+ batches.map((batch) =>
+ engine.query(ORG_UNIT_DETAILS_QUERY, {
+ variables: { ids: batch },
+ })
+ )
+ )
+
+ return results.reduce((acc, result) => {
+ result.orgUnits.organisationUnits?.forEach((ou) => {
+ acc[ou.id] = { level: ou.level, parentName: ou.parent?.name }
+ })
+ return acc
+ }, {})
+ } catch {
+ return {}
+ }
+}
+
+export const addGroupCountsToLegend = (legendItems, features, groupSet) => {
+ legendItems.forEach((item) => (item.count = 0))
+ features.forEach((f) => {
+ const groupId = f.properties?.dimensions?.[groupSet.id]
+ const item = groupId && legendItems.find((i) => i.id === groupId)
+ if (item) {
+ item.count++
+ }
+ })
+}
+
+export const addLevelCountsToLegend = (
+ legendItems,
+ features,
+ orgUnitLevels
+) => {
+ legendItems.forEach((item) => (item.count = 0))
+ features.forEach((f) => {
+ const levelInfo = orgUnitLevels.find(
+ (l) => l.level === f.properties.level
+ )
+ const item =
+ levelInfo &&
+ legendItems.find((i) => i.name === levelInfo.displayName)
+ if (item) {
+ item.count++
+ }
+ })
+}
+
+export const fetchAndParseGroupSet = async (engine, groupSet) => {
+ try {
+ const { groupSets } = await engine.query(ORG_UNITS_GROUP_SET_QUERY, {
+ variables: { id: groupSet?.id },
+ })
+ return {
+ organisationUnitGroups: parseGroupSet({
+ organisationUnitGroups: groupSets.organisationUnitGroups,
+ }),
+ name: groupSets.name,
+ }
+ } catch {
+ return null
+ }
+}
+
+export const loadGroupSetData = async (engine, groupSet, includeGroupSets) => {
+ if (!includeGroupSets || groupSet.organisationUnitGroups) {
+ return null
+ }
+ const parsed = await fetchAndParseGroupSet(engine, groupSet)
+ if (!parsed) {
+ return i18n.t('GroupSet used for styling was not found')
+ }
+ groupSet.organisationUnitGroups = parsed.organisationUnitGroups
+ groupSet.name = parsed.name
+ return null
+}
+
+export const fetchAssociatedGeometries = async (
+ engine,
+ {
+ orgUnitIds,
+ keyAnalysisDisplayProperty,
+ includeGroupSets,
+ coordinateField,
+ userId,
+ },
+ filterGeoFeatures = (items) => items
+) => {
+ const rawData = await engine.query(GEOFEATURES_QUERY, {
+ variables: {
+ orgUnitIds,
+ keyAnalysisDisplayProperty,
+ includeGroupSets,
+ coordinateField: coordinateField.id,
+ userId,
+ },
+ })
+ return rawData?.geoFeatures
+ ? toGeoJson(filterGeoFeatures(rawData.geoFeatures))
+ : null
+}
+
// Combines main org unit features with associated geometries
export const addAssociatedGeometries = (mainFeatures, associatedGeometries) => {
// Return main features if there are no associated geomteries
diff --git a/src/util/requests.js b/src/util/requests.js
index e569b00b5..b97840cf5 100644
--- a/src/util/requests.js
+++ b/src/util/requests.js
@@ -143,3 +143,34 @@ export const GEOFEATURES_QUERY = {
}),
},
}
+
+export const FIRST_DATA_ELEMENT_QUERY = {
+ dataElements: {
+ resource: 'dataElements',
+ params: { pageSize: 1, fields: 'id' },
+ },
+}
+
+export const ORG_UNITS_COUNT_QUERY = {
+ orgUnitsCount: {
+ resource: 'analytics',
+ params: ({ dataElementId, orgUnitIds, userId }) => ({
+ dimension: `dx:${dataElementId},ou:${orgUnitIds.join(';')}`,
+ filter: 'pe:THIS_YEAR',
+ skipData: true,
+ skipMeta: false,
+ _: userId,
+ }),
+ },
+}
+
+export const ORG_UNIT_DETAILS_QUERY = {
+ orgUnits: {
+ resource: 'organisationUnits',
+ params: ({ ids }) => ({
+ filter: `id:in:[${ids.join(',')}]`,
+ fields: 'id,level,parent[displayName~rename(name)]',
+ paging: false,
+ }),
+ },
+}