diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js
index 6e6bfc048f..cfc3698411 100644
--- a/cypress/integration/dataTable.cy.js
+++ b/cypress/integration/dataTable.cy.js
@@ -85,8 +85,8 @@ describe('data table', () => {
.findByDataTest('dhis2-uicore-datatablecellhead')
.should('have.length', 10)
- // Filter by name
- cy.getByDataTest('data-table-column-filter-search-Name')
+ // Filter by Org unit
+ cy.getByDataTest('data-table-column-filter-search-Org unit')
.find('input')
.type('bar{enter}')
@@ -97,11 +97,12 @@ describe('data table', () => {
.should('have.length', 7)
// Confirm that the sort order is initially ascending by Name
- checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' })
- checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' })
+ checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' })
+ checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' })
- // Sort by name
- cy.getByDataTest('data-table-column-sort-button-Name').click()
+ // Sort by name (descending)
+ cy.getByDataTest('data-table-column-sort-button-Org unit').click()
+ cy.getByDataTest('data-table-column-sort-button-Org unit').click()
// Sorting can shift the virtualized table's scroll position
// (possibly an internal react-virtuoso quirk)
@@ -109,8 +110,8 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')
// Confirm that the rows are sorted by Name descending
- checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' })
- checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' })
+ checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' })
+ checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' })
// Filter by Value (numeric)
cy.getByDataTest('data-table-column-filter-search-Value')
@@ -130,8 +131,8 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')
// Check that the rows are sorted by Value ascending
- checkTableCell({ row: 0, column: 3, expectedContent: '35' })
- checkTableCell({ row: 4, column: 3, expectedContent: '76' })
+ checkTableCell({ row: 0, column: 5, expectedContent: '35' })
+ checkTableCell({ row: 4, column: 5, expectedContent: '76' })
// Right-click a row and select "View profile"
cy.getByDataTest('bottom-panel')
@@ -161,7 +162,7 @@ describe('data table', () => {
assertMapPosition(expectedBottoms1, expectedHeights1)
})
- it('opens the data table for an Event layer', () => {
+ it('opens data table for an Event layer', () => {
cy.visit('/')
const EvenLayer = new EventLayer()
@@ -195,7 +196,7 @@ describe('data table', () => {
// Check number of columns
cy.getByDataTest('bottom-panel')
.findByDataTest('dhis2-uicore-datatablecellhead')
- .should('have.length', 10)
+ .should('have.length', 13)
cy.getByDataTest('bottom-panel')
.findByDataTest('dhis2-uicore-datatablecellhead')
@@ -209,8 +210,8 @@ describe('data table', () => {
.type(`${ouName}{enter}`)
// Check that all the rows have Org unit Moyowa
- checkTableCell({ row: 0, column: 1, expectedContent: ouName })
- checkTableCell({ row: 2, column: 1, expectedContent: ouName })
+ checkTableCell({ row: 0, column: 3, expectedContent: ouName })
+ checkTableCell({ row: 2, column: 3, expectedContent: ouName })
cy.getByDataTest('bottom-panel')
.findByDataTest('dhis2-uicore-tablebody')
@@ -255,8 +256,8 @@ describe('data table', () => {
// Confirm that the rows are sorted by Age in years ascending
// (the first click on a new column always sorts ascending)
- checkTableCell({ row: 0, column: 7, expectedContent: '6' })
- checkTableCell({ row: 1, column: 7, expectedContent: '32' })
+ checkTableCell({ row: 0, column: 10, expectedContent: '6' })
+ checkTableCell({ row: 1, column: 10, expectedContent: '32' })
// Right-click a row: Event layers have no profile to view
cy.getByDataTest('bottom-panel')
@@ -317,7 +318,7 @@ describe('data table', () => {
cy.getByDataTest('layers-toggle-button').click()
// Confirm that the sort order is initially ascending by Name
- checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' })
+ checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' })
// First click on a new column always sorts ascending
cy.getByDataTest('data-table-column-sort-button-Value').click()
@@ -326,15 +327,15 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')
// Check that first row has Tihun CHC with value 28.63
- checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' })
- checkTableCell({ row: 0, column: 3, expectedContent: '28.63' })
+ checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' })
+ checkTableCell({ row: 0, column: 5, expectedContent: '28.63' })
// Check that row 5 has Gbamgbama CHC with value 117.98
- checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' })
- checkTableCell({ row: 5, column: 3, expectedContent: '117.98' })
+ checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' })
+ checkTableCell({ row: 5, column: 5, expectedContent: '117.98' })
// Check that row 6 has no value (undefined)
- checkTableCell({ row: 6, column: 3, expectedContent: '' })
+ checkTableCell({ row: 6, column: 5, expectedContent: '' })
// Sort descending by Value
cy.getByDataTest('data-table-column-sort-button-Value').click()
@@ -342,13 +343,13 @@ describe('data table', () => {
// Reset scroll position after sorting - see comment above
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')
- checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' })
- checkTableCell({ row: 0, column: 3, expectedContent: '117.98' })
+ checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' })
+ checkTableCell({ row: 0, column: 5, expectedContent: '117.98' })
- checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' })
- checkTableCell({ row: 5, column: 3, expectedContent: '28.63' })
+ checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' })
+ checkTableCell({ row: 5, column: 5, expectedContent: '28.63' })
- checkTableCell({ row: 6, column: 3, expectedContent: '' })
+ checkTableCell({ row: 6, column: 5, expectedContent: '' })
// Third click on the same column cycles back to natural (unsorted)
// order - there's no dedicated Index column/button any more
@@ -358,7 +359,7 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')
// Check that row 0 range value is empty
- checkTableCell({ row: 0, column: 8, expectedContent: '' })
+ checkTableCell({ row: 0, column: 7, expectedContent: '' })
// Sort by range, which is a string
cy.getByDataTest('data-table-column-sort-button-Range').click()
@@ -367,12 +368,12 @@ describe('data table', () => {
cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top')
// Check that row 0 range value has value '0-40'
- checkTableCell({ row: 0, column: 8, expectedContent: '0 – 40' })
+ checkTableCell({ row: 0, column: 7, expectedContent: '0 – 40' })
// Check that row 5 range value has value '90 - 120'
- checkTableCell({ row: 5, column: 8, expectedContent: '90 – 120' })
+ checkTableCell({ row: 5, column: 7, expectedContent: '90 – 120' })
// Check that row 6 range value is empty
- checkTableCell({ row: 6, column: 8, expectedContent: '' })
+ checkTableCell({ row: 6, column: 7, expectedContent: '' })
})
})
diff --git a/i18n/en.pot b/i18n/en.pot
index 9375e4be98..4aa2ba8ed9 100644
--- a/i18n/en.pot
+++ b/i18n/en.pot
@@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
-"POT-Creation-Date: 2026-09-07T08:34:02.249Z\n"
-"PO-Revision-Date: 2026-09-07T08:34:02.249Z\n"
+"POT-Creation-Date: 2026-09-07T10:22:12.476Z\n"
+"PO-Revision-Date: 2026-09-07T10:22:12.476Z\n"
msgid "2020"
msgstr "2020"
@@ -170,6 +170,15 @@ msgstr "Sort by {{column}}"
msgid "Edit layer"
msgstr "Edit layer"
+msgid "Select a year, month, day or hour"
+msgstr "Select a year, month, day or hour"
+
+msgid "to match the events under it, or type to search"
+msgstr "to match the events under it, or type to search"
+
+msgid "Contains"
+msgstr "Contains"
+
msgid "Something went wrong"
msgstr "Something went wrong"
@@ -197,9 +206,6 @@ msgstr "to match rows that contain it"
msgid "Use filter"
msgstr "Use filter"
-msgid "Contains"
-msgstr "Contains"
-
msgid "Search or type > 5, < 8…"
msgstr "Search or type > 5, < 8…"
@@ -221,6 +227,21 @@ msgstr "No matches"
msgid "No value"
msgstr "No value"
+msgid "Collapse {{label}}"
+msgstr "Collapse {{label}}"
+
+msgid "Expand {{label}}"
+msgstr "Expand {{label}}"
+
+msgid "Select a country, region, district or facility"
+msgstr "Select a country, region, district or facility"
+
+msgid "to match the rows under it, or type to search"
+msgstr "to match the rows under it, or type to search"
+
+msgid "Select matches"
+msgstr "Select matches"
+
msgid "Selected"
msgstr "Selected"
@@ -2059,18 +2080,30 @@ msgstr "GroupSet used for styling was not found"
msgid "Id"
msgstr "Id"
-msgid "Type"
-msgstr "Type"
-
-msgid "Range"
-msgstr "Range"
+msgid "Org unit Id"
+msgstr "Org unit Id"
msgid "Org unit"
msgstr "Org unit"
+msgid "Org unit level"
+msgstr "Org unit level"
+
+msgid "Geometry type"
+msgstr "Geometry type"
+
+msgid "Range"
+msgstr "Range"
+
msgid "Org unit boundary"
msgstr "Org unit boundary"
+msgid "Org unit hierarchy"
+msgstr "Org unit hierarchy"
+
+msgid "Created"
+msgstr "Created"
+
msgid "Group"
msgstr "Group"
@@ -2083,6 +2116,12 @@ msgstr "Current period"
msgid "Value ({{period}})"
msgstr "Value ({{period}})"
+msgid "Event Id"
+msgstr "Event Id"
+
+msgid "Tracked entity Id"
+msgstr "Tracked entity Id"
+
msgid "Start date is invalid"
msgstr "Start date is invalid"
diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx
index c03403a3f0..1e10612949 100644
--- a/src/components/datatable/DataTable.jsx
+++ b/src/components/datatable/DataTable.jsx
@@ -172,6 +172,7 @@ const Table = ({
totalCount,
filteredCount,
columnOptions,
+ orgUnitIdToName,
} = useTableData({
layer,
sortField,
@@ -467,6 +468,7 @@ const Table = ({
options={columnOptions[dataKey]}
optionSetId={optionSet?.id}
renderer={renderer}
+ orgUnitIdToName={orgUnitIdToName}
/>
)
}
@@ -528,6 +530,7 @@ const Table = ({
isAllSelected,
onToggleSelectAll,
headerRowRef,
+ orgUnitIdToName,
]
)
@@ -551,6 +554,7 @@ const Table = ({
rendererByDataKey={rendererByDataKey}
typeByDataKey={typeByDataKey}
keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator}
+ orgUnitIdToName={orgUnitIdToName}
onToggleSelection={onToggleSelection}
/>
),
@@ -566,6 +570,7 @@ const Table = ({
rendererByDataKey,
typeByDataKey,
keyAnalysisDigitGroupSeparator,
+ orgUnitIdToName,
onToggleSelection,
]
)
diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx
new file mode 100644
index 0000000000..3c7131c764
--- /dev/null
+++ b/src/components/datatable/DateGroupFilterInput.jsx
@@ -0,0 +1,86 @@
+import i18n from '@dhis2/d2-i18n'
+import PropTypes from 'prop-types'
+import React, { useCallback } from 'react'
+import { setDataFilter } from '../../actions/dataFilters.js'
+import { DATE_GROUPS_GRANULARITY } from '../../constants/dataTable.js'
+import {
+ buildDateGroupTree,
+ formatNodeLabel,
+ getSearchMatches,
+} from '../../util/dateGroups.js'
+import { isDateGroupFilter } from '../../util/filter.js'
+import GroupFilterPopover from './GroupFilterPopover.jsx'
+import useGroupFilterInput from './useGroupFilterInput.js'
+
+const HELP_CONTENT = (
+
+
{i18n.t('Select a year, month, day or hour')}
+
{i18n.t('to match the events under it, or type to search')}
+
+)
+const DATE_INPUT_DISALLOWED = /[^0-9\-:. T]/g
+
+const parseFilterValue = (filterValue) => ({
+ selectedPrefixes: isDateGroupFilter(filterValue)
+ ? filterValue.prefixes
+ : [],
+ appliedString: typeof filterValue === 'string' ? filterValue : '',
+})
+
+const sanitizeInput = (value) => value.replace(DATE_INPUT_DISALLOWED, '')
+
+const commitSearch = (text, { dispatch, layerId, dataKey }) =>
+ dispatch(setDataFilter(layerId, dataKey, text))
+
+const DateGroupFilterInput = ({
+ dataKey,
+ name,
+ layerId,
+ filterValue,
+ options,
+ type,
+}) => {
+ const buildTree = useCallback(
+ (realValues) => buildDateGroupTree(realValues, type),
+ [type]
+ )
+
+ const groupFilter = useGroupFilterInput({
+ dataKey,
+ layerId,
+ filterValue,
+ options,
+ granularity: DATE_GROUPS_GRANULARITY,
+ buildTree,
+ getMatches: getSearchMatches,
+ parseFilterValue,
+ commitSearch,
+ sanitizeInput,
+ })
+
+ return (
+ formatNodeLabel(node, i18n.language)}
+ />
+ )
+}
+
+DateGroupFilterInput.propTypes = {
+ dataKey: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string }))
+ .isRequired,
+ type: PropTypes.string.isRequired,
+ filterValue: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.arrayOf(PropTypes.string),
+ PropTypes.object,
+ ]),
+ layerId: PropTypes.string,
+}
+
+export default DateGroupFilterInput
diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx
index 0741860ad6..846f23acae 100644
--- a/src/components/datatable/FilterInput.jsx
+++ b/src/components/datatable/FilterInput.jsx
@@ -11,10 +11,15 @@ import {
SENTINEL_NO_VALUE,
RENDERER_COLOR,
RENDERER_ICON,
+ RENDERER_ORG_UNIT,
+ RENDERER_ORG_UNIT_NAME,
+ RENDERER_BOOLEAN,
TYPE_NUMBER,
- // TYPE_DATE,
- // TYPE_DATETIME,
- // TYPE_TIME,
+ TYPE_DATE,
+ TYPE_DATETIME,
+ TYPE_TIME,
+ TYPE_ORG_UNIT,
+ ORG_UNIT_ID_DATA_KEY,
} from '../../constants/dataTable.js'
import useOptionSet from '../../hooks/useOptionSet.js'
import {
@@ -35,15 +40,21 @@ import {
toggleAnyValue,
toggleRealValue,
} from '../../util/filterSelection.js'
+import { formatBoolean } from '../../util/helpers.js'
import { formatWithSeparator } from '../../util/numbers.js'
+import {
+ formatOrgUnitPathBreadcrumb,
+ formatOrgUnitOwnName,
+} from '../../util/orgUnitGroups.js'
import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx'
import Checkbox from '../core/Checkbox.jsx'
-// import DateGroupFilterInput from './DateGroupFilterInput.jsx'
+import DateGroupFilterInput from './DateGroupFilterInput.jsx'
import {
FilterDropdownPopover,
getDropdownPlacement,
} from './FilterDropdownPopover.jsx'
import FilterHelpTooltip from './FilterHelpTooltip.jsx'
+import OrgUnitGroupFilterInput from './OrgUnitGroupFilterInput.jsx'
import styles from './styles/FilterInput.module.css'
const NUMERIC_HELP_HEIGHT = 140
@@ -110,10 +121,30 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
applyValues(next)
}
- const applyCustomFilter = (text) =>
- text
- ? dispatch(setDataFilter(layerId, dataKey, text))
- : dispatch(clearDataFilter(layerId, dataKey))
+ const isOrgUnitRenderer =
+ renderer === RENDERER_ORG_UNIT || renderer === RENDERER_ORG_UNIT_NAME
+
+ const applyCustomFilter = (text) => {
+ if (!text) {
+ dispatch(clearDataFilter(layerId, dataKey))
+ return
+ }
+ if (isOrgUnitRenderer) {
+ const lower = text.toLowerCase()
+ const values = realValues.filter((value) =>
+ resolveLabel(value).toLowerCase().includes(lower)
+ )
+ dispatch(
+ setDataFilter(layerId, dataKey, {
+ values,
+ searchDerived: true,
+ searchText: text,
+ })
+ )
+ return
+ }
+ dispatch(setDataFilter(layerId, dataKey, text))
+ }
const isIconColumn = renderer === RENDERER_ICON
@@ -156,9 +187,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
const font = `11px ${getComputedStyle(document.body).fontFamily}`
const maxLabelWidth = measureMaxTextWidth(labels, font)
return getPopoverWidth(maxLabelWidth)
- // resolveLabel's identity only changes alongside type/optionSet, which don't change without realOptions changing too
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [realOptions, hasNotSetOption])
+ }, [realOptions, hasNotSetOption, resolveLabel])
const onToggleAnyValue = () => applyValues(toggleAnyValue(selected))
@@ -446,6 +475,8 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
className={cx(
styles.denseCheckbox,
(dataKey === 'id' ||
+ dataKey ===
+ ORG_UNIT_ID_DATA_KEY ||
renderer ===
RENDERER_COLOR) &&
styles.monoOption,
@@ -484,7 +515,7 @@ SearchableFilterPopover.propTypes = {
}
const PlainSearchableFilter = (props) => {
- const { type } = props
+ const { type, renderer, orgUnitIdToName } = props
const {
systemSettings: { keyAnalysisDigitGroupSeparator },
} = useCachedData()
@@ -494,6 +525,15 @@ const PlainSearchableFilter = (props) => {
if (value === SENTINEL_NO_VALUE) {
return i18n.t('No value')
}
+ if (renderer === RENDERER_ORG_UNIT) {
+ return formatOrgUnitPathBreadcrumb(value, orgUnitIdToName)
+ }
+ if (renderer === RENDERER_ORG_UNIT_NAME) {
+ return formatOrgUnitOwnName(value, orgUnitIdToName)
+ }
+ if (renderer === RENDERER_BOOLEAN) {
+ return formatBoolean(value)
+ }
return type === TYPE_NUMBER
? formatWithSeparator(
Number(value),
@@ -501,13 +541,15 @@ const PlainSearchableFilter = (props) => {
)
: value
},
- [type, keyAnalysisDigitGroupSeparator]
+ [type, renderer, orgUnitIdToName, keyAnalysisDigitGroupSeparator]
)
return
}
PlainSearchableFilter.propTypes = {
+ orgUnitIdToName: PropTypes.instanceOf(Map),
+ renderer: PropTypes.string,
type: PropTypes.string,
}
@@ -545,6 +587,7 @@ const FilterInput = React.memo(function FilterInput({
options,
optionSetId,
renderer,
+ orgUnitIdToName,
}) {
const dataTable = useSelector((state) => state.dataTable)
const map = useSelector((state) => state.map)
@@ -561,32 +604,51 @@ const FilterInput = React.memo(function FilterInput({
const filterValue = filters?.[dataKey]
- /* const isDateType =
- type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME */
+ const isDateType =
+ type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME
+
+ if (isDateType) {
+ return (
+
+ )
+ }
- /* return isDateType ? (
-
- ) : */
+ if (type === TYPE_ORG_UNIT) {
+ return (
+
+ )
+ }
- return optionSetId ? (
-
- ) : (
+ if (optionSetId) {
+ return (
+
+ )
+ }
+
+ return (
)
})
@@ -605,6 +668,7 @@ FilterInput.propTypes = {
type: PropTypes.string.isRequired,
optionSetId: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })),
+ orgUnitIdToName: PropTypes.instanceOf(Map),
renderer: PropTypes.string,
}
diff --git a/src/components/datatable/GroupFilterPopover.jsx b/src/components/datatable/GroupFilterPopover.jsx
new file mode 100644
index 0000000000..36b02108de
--- /dev/null
+++ b/src/components/datatable/GroupFilterPopover.jsx
@@ -0,0 +1,269 @@
+import i18n from '@dhis2/d2-i18n'
+import {
+ Input,
+ IconChevronRight16,
+ IconChevronDown16,
+ IconFilter16,
+} from '@dhis2/ui'
+import cx from 'classnames'
+import PropTypes from 'prop-types'
+import React from 'react'
+import { Virtuoso } from 'react-virtuoso'
+import {
+ OPTION_ROW_HEIGHT,
+ MAX_LIST_HEIGHT,
+ toHighlightedIndex,
+} from '../../util/filterInput.js'
+import Checkbox from '../core/Checkbox.jsx'
+import { FilterDropdownPopover } from './FilterDropdownPopover.jsx'
+import FilterHelpTooltip from './FilterHelpTooltip.jsx'
+import styles from './styles/FilterInput.module.css'
+
+const GROUP_POPOVER_WIDTH = 220
+const HELP_HEIGHT = 56
+const INDENT_PX = 16
+
+const GroupFilterPopover = ({
+ name,
+ helpContent,
+ customFilterTag,
+ formatLabel,
+ anchorRef,
+ listRef,
+ dropdownPlacement,
+ dropdownSide,
+ tooltipPlacement,
+ isOpen,
+ searchText,
+ highlightedIndex,
+ displayValue,
+ visibleNodes,
+ showCustomFilterRow,
+ anyValueActive,
+ notSetActive,
+ hasNotSetOption,
+ effectiveExpanded,
+ checkStateFor,
+ openPopover,
+ closePopover,
+ onSearchChange,
+ onSearchKeyDown,
+ onApplyCustomFilterClick,
+ onToggleExpand,
+ onToggleNode,
+ onToggleAnyValue,
+ onToggleNotSet,
+}) => (
+
+
+ {
+ if (!isOpen) {
+ openPopover()
+ }
+ }}
+ onChange={onSearchChange}
+ onKeyDown={onSearchKeyDown}
+ />
+
+ {isOpen && (
+
+
+ {showCustomFilterRow && (
+
+ )}
+
+
+ {hasNotSetOption && (
+
+ )}
+
+
+ {!showCustomFilterRow && visibleNodes.length === 0 && (
+
+ {i18n.t('No matches')}
+
+ )}
+ {visibleNodes.length > 0 && (
+
node.key}
+ itemContent={(index, { node, depth }) => {
+ const state = checkStateFor(node)
+ const checked = state === 'checked'
+ const indeterminate =
+ state === 'indeterminate'
+ const isExpanded = effectiveExpanded.has(
+ node.key
+ )
+ const label = formatLabel(node)
+ return (
+
+ {node.children.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ onToggleNode(node)
+ }
+ className={cx(
+ styles.denseCheckbox,
+ highlightedIndex ===
+ toHighlightedIndex(
+ index,
+ showCustomFilterRow
+ ) && styles.highlighted
+ )}
+ />
+
+ )
+ }}
+ />
+ )}
+
+
+
+ )}
+
+)
+
+GroupFilterPopover.propTypes = {
+ anchorRef: PropTypes.object.isRequired,
+ anyValueActive: PropTypes.bool.isRequired,
+ checkStateFor: PropTypes.func.isRequired,
+ closePopover: PropTypes.func.isRequired,
+ customFilterTag: PropTypes.string.isRequired,
+ displayValue: PropTypes.string.isRequired,
+ effectiveExpanded: PropTypes.instanceOf(Set).isRequired,
+ formatLabel: PropTypes.func.isRequired,
+ hasNotSetOption: PropTypes.bool.isRequired,
+ helpContent: PropTypes.node.isRequired,
+ highlightedIndex: PropTypes.number.isRequired,
+ isOpen: PropTypes.bool.isRequired,
+ listRef: PropTypes.object.isRequired,
+ name: PropTypes.string.isRequired,
+ notSetActive: PropTypes.bool.isRequired,
+ openPopover: PropTypes.func.isRequired,
+ searchText: PropTypes.string.isRequired,
+ showCustomFilterRow: PropTypes.bool.isRequired,
+ visibleNodes: PropTypes.array.isRequired,
+ onApplyCustomFilterClick: PropTypes.func.isRequired,
+ onSearchChange: PropTypes.func.isRequired,
+ onSearchKeyDown: PropTypes.func.isRequired,
+ onToggleAnyValue: PropTypes.func.isRequired,
+ onToggleExpand: PropTypes.func.isRequired,
+ onToggleNode: PropTypes.func.isRequired,
+ onToggleNotSet: PropTypes.func.isRequired,
+ dropdownPlacement: PropTypes.string,
+ dropdownSide: PropTypes.string,
+ tooltipPlacement: PropTypes.string,
+}
+
+export default GroupFilterPopover
diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx
new file mode 100644
index 0000000000..84198946f9
--- /dev/null
+++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx
@@ -0,0 +1,122 @@
+import i18n from '@dhis2/d2-i18n'
+import PropTypes from 'prop-types'
+import React, { useCallback } from 'react'
+import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js'
+import { ORG_UNIT_GROUPS_GRANULARITY } from '../../constants/dataTable.js'
+import { isOrgUnitGroupFilter } from '../../util/filter.js'
+import {
+ buildOrgUnitGroupTree,
+ formatOrgUnitNodeLabel,
+ getOrgUnitSearchMatches,
+} from '../../util/orgUnitGroups.js'
+import { flattenAllNodes } from '../../util/prefixTree.js'
+import GroupFilterPopover from './GroupFilterPopover.jsx'
+import useGroupFilterInput from './useGroupFilterInput.js'
+
+const HELP_CONTENT = (
+
+
{i18n.t('Select a country, region, district or facility')}
+
{i18n.t('to match the rows under it, or type to search')}
+
+)
+
+const getAppliedString = (filterValue) => {
+ if (isOrgUnitGroupFilter(filterValue)) {
+ return filterValue.searchDerived ? filterValue.searchText : ''
+ }
+ return typeof filterValue === 'string' ? filterValue : ''
+}
+
+const parseFilterValue = (filterValue) => ({
+ selectedPrefixes:
+ isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived
+ ? filterValue.prefixes
+ : [],
+ appliedString: getAppliedString(filterValue),
+})
+
+const OrgUnitGroupFilterInput = ({
+ dataKey,
+ name,
+ layerId,
+ filterValue,
+ options,
+ idToName,
+}) => {
+ const getMatches = useCallback(
+ (tree, normalizedSearch) =>
+ getOrgUnitSearchMatches(tree, normalizedSearch, idToName),
+ [idToName]
+ )
+
+ const commitSearch = useCallback(
+ (
+ text,
+ { tree, dispatch, layerId: layerIdArg, dataKey: dataKeyArg }
+ ) => {
+ const matches = getOrgUnitSearchMatches(
+ tree,
+ text.toLowerCase(),
+ idToName
+ )
+ const nodeByKey = new Map(
+ flattenAllNodes(tree).map((node) => [node.key, node])
+ )
+ const matchedPrefixes = [...matches.matchedKeys]
+ .map((key) => nodeByKey.get(key))
+ .filter(Boolean)
+ .map((node) => node.prefix)
+ if (!matchedPrefixes.length) {
+ dispatch(clearDataFilter(layerIdArg, dataKeyArg))
+ return
+ }
+ dispatch(
+ setDataFilter(layerIdArg, dataKeyArg, {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: matchedPrefixes,
+ searchDerived: true,
+ searchText: text,
+ })
+ )
+ },
+ [idToName]
+ )
+
+ const groupFilter = useGroupFilterInput({
+ dataKey,
+ layerId,
+ filterValue,
+ options,
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ buildTree: buildOrgUnitGroupTree,
+ getMatches,
+ parseFilterValue,
+ commitSearch,
+ })
+
+ return (
+ formatOrgUnitNodeLabel(node, idToName)}
+ />
+ )
+}
+
+OrgUnitGroupFilterInput.propTypes = {
+ dataKey: PropTypes.string.isRequired,
+ idToName: PropTypes.instanceOf(Map).isRequired,
+ name: PropTypes.string.isRequired,
+ options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string }))
+ .isRequired,
+ filterValue: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.arrayOf(PropTypes.string),
+ PropTypes.object,
+ ]),
+ layerId: PropTypes.string,
+}
+
+export default OrgUnitGroupFilterInput
diff --git a/src/components/datatable/RowCells.jsx b/src/components/datatable/RowCells.jsx
index 541298e884..b0ef0b2fb4 100644
--- a/src/components/datatable/RowCells.jsx
+++ b/src/components/datatable/RowCells.jsx
@@ -6,12 +6,24 @@ import {
RENDERER_COLOR,
RENDERER_ICON,
RENDERER_DATE,
+ RENDERER_ORG_UNIT,
+ RENDERER_ORG_UNIT_NAME,
+ RENDERER_BOOLEAN,
TYPE_DATE,
+ ORG_UNIT_ID_DATA_KEY,
} from '../../constants/dataTable.js'
import { isDarkColor } from '../../util/colors.js'
import { getRowId } from '../../util/dataTable.js'
-import { formatDate, formatDatetime } from '../../util/helpers.js'
+import {
+ formatBoolean,
+ formatDate,
+ formatDatetime,
+} from '../../util/helpers.js'
import { formatWithSeparator } from '../../util/numbers.js'
+import {
+ formatOrgUnitOwnName,
+ formatOrgUnitPathBreadcrumb,
+} from '../../util/orgUnitGroups.js'
import { getPinnedCellProps } from '../../util/tableColumns.js'
import styles from './styles/DataTable.module.css'
@@ -28,6 +40,7 @@ const RowCells = ({
rendererByDataKey,
typeByDataKey,
keyAnalysisDigitGroupSeparator,
+ orgUnitIdToName,
onToggleSelection,
}) => {
const rowId = getRowId(row)
@@ -78,6 +91,9 @@ const RowCells = ({
const isIconCell = renderer === RENDERER_ICON
const isDateCell = renderer === RENDERER_DATE
const isDateOnlyCell = typeByDataKey.get(dataKey) === TYPE_DATE
+ const isOrgUnitHierarchyCell = renderer === RENDERER_ORG_UNIT
+ const isOrgUnitNameCell = renderer === RENDERER_ORG_UNIT_NAME
+ const isBooleanCell = renderer === RENDERER_BOOLEAN
return (
{
+ const store = mockStore({})
+ const result = render(
+
+
+
+
+
+ )
+ return { ...result, store }
+}
+
+const getInput = () =>
+ screen
+ .getByTestId('data-table-column-filter-search-Event date')
+ .querySelector('input')
+
+const openPopover = () => fireEvent.focus(getInput())
+
+describe('DateGroupFilterInput - default (collapsed) tree', () => {
+ test('shows only root (year) nodes by default', () => {
+ renderDateGroupFilter()
+ openPopover()
+ expect(screen.getByLabelText('2023')).toBeInTheDocument()
+ expect(screen.getByLabelText('2024')).toBeInTheDocument()
+ expect(screen.queryByLabelText('May')).not.toBeInTheDocument()
+ })
+
+ test('expanding a year reveals its months', () => {
+ renderDateGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand 2023'))
+ expect(screen.getByLabelText('May')).toBeInTheDocument()
+ })
+
+ test('expanding down to the day level reveals hours for a DATETIME column, and the exact value under an hour', () => {
+ renderDateGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand 2023'))
+ fireEvent.click(screen.getByLabelText('Expand May'))
+ fireEvent.click(screen.getByLabelText('Expand 15 Monday'))
+ expect(screen.getByLabelText('09:00')).toBeInTheDocument()
+ expect(screen.getByLabelText('14:00')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Expand 09:00'))
+ expect(screen.getByLabelText('2023-05-15 09:00')).toBeInTheDocument()
+ })
+
+ test('collapsing a year hides its months again', () => {
+ renderDateGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand 2023'))
+ expect(screen.getByLabelText('May')).toBeInTheDocument()
+ fireEvent.click(screen.getByLabelText('Collapse 2023'))
+ expect(screen.queryByLabelText('May')).not.toBeInTheDocument()
+ })
+})
+
+describe('DateGroupFilterInput - selection dispatches', () => {
+ test('checking a year dispatches the full date-group filter shape', () => {
+ const { store } = renderDateGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('2023'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ filter: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ })
+
+ test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => {
+ const { store } = renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('2023'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_CLEAR,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ })
+ })
+
+ test('checking a month drops the now-redundant year-level ancestor selection scenario in reverse: checking a day under an unrelated selected month keeps both', () => {
+ const { store } = renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2024'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand 2023'))
+ fireEvent.click(screen.getByLabelText('May'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ filter: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2024', '2023-05'],
+ },
+ })
+ })
+})
+
+describe('DateGroupFilterInput - tri-state checkbox rendering', () => {
+ test('a year is checked when its own prefix is selected', () => {
+ renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ openPopover()
+ expect(screen.getByLabelText('2023')).toBeChecked()
+ })
+
+ test('a year is indeterminate when only a descendant prefix is selected', () => {
+ renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023-05'],
+ },
+ })
+ openPopover()
+ const yearCheckbox = screen.getByLabelText('2023')
+ expect(yearCheckbox.indeterminate).toBe(true)
+ expect(yearCheckbox.checked).toBe(false)
+ })
+
+ test('a month is checked (not indeterminate) when its ancestor year is selected', () => {
+ renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand 2023'))
+ const monthCheckbox = screen.getByLabelText('May')
+ expect(monthCheckbox.checked).toBe(true)
+ expect(monthCheckbox.indeterminate).toBe(false)
+ })
+})
+
+describe('DateGroupFilterInput - "Any value" / "No value"', () => {
+ test('"No value" is only shown when the options include the not-set sentinel', () => {
+ renderDateGroupFilter()
+ openPopover()
+ expect(screen.queryByLabelText('No value')).not.toBeInTheDocument()
+ })
+
+ test('checking "Any value" dispatches the sentinel and clears prior selections', () => {
+ const { store } = renderDateGroupFilter({
+ options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }],
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Any value'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ filter: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: [SENTINEL_ANY_VALUE],
+ },
+ })
+ })
+
+ test('checking "No value" preserves an existing tree selection alongside it', () => {
+ const { store } = renderDateGroupFilter({
+ options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }],
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('No value'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ filter: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023', SENTINEL_NO_VALUE],
+ },
+ })
+ })
+
+ test('clicking a tree node while "Any value" is active is a no-op (v1 scope boundary)', () => {
+ const { store } = renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: [SENTINEL_ANY_VALUE],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('2023'))
+ expect(store.getActions()).toEqual([])
+ })
+})
+
+describe('DateGroupFilterInput - clearing via the input’s clear ("x") button', () => {
+ test('clearing the closed trigger (showing "N selected") clears the whole filter', () => {
+ const { store } = renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ expect(getInput()).toHaveValue('1 selected')
+ fireEvent.change(getInput(), { target: { value: '' } })
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_CLEAR,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ })
+ })
+
+ test('clearing a typed search narrow while a selection is active also clears the selection (mirrors the flat filter variant)', () => {
+ const { store } = renderDateGroupFilter({
+ filterValue: {
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
+ },
+ })
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: '2023-05' } })
+ expect(screen.queryByLabelText('2024')).not.toBeInTheDocument()
+
+ fireEvent.change(getInput(), { target: { value: '' } })
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_CLEAR,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ })
+ })
+
+ test('clearing empty search text with no active filter dispatches nothing', () => {
+ const { store } = renderDateGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: '' } })
+ expect(store.getActions()).toEqual([])
+ })
+})
+
+describe('DateGroupFilterInput - search', () => {
+ test('typing narrows to matching branches and auto-expands their ancestors', () => {
+ renderDateGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: '2023-05' } })
+ expect(screen.getByLabelText('2023')).toBeInTheDocument()
+ expect(screen.getByLabelText('May')).toBeInTheDocument()
+ expect(screen.queryByLabelText('2024')).not.toBeInTheDocument()
+ })
+
+ test('typed letters are stripped, so a matching numeric prefix still narrows even amid disallowed characters', () => {
+ renderDateGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: 'x2023-05y' } })
+ expect(getInput()).toHaveValue('2023-05')
+ expect(screen.getByLabelText('May')).toBeInTheDocument()
+ expect(screen.queryByLabelText('2024')).not.toBeInTheDocument()
+ })
+
+ test('typing text with no exact tree match shows a live-applying "Contains" custom filter row', () => {
+ const { store } = renderDateGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: '2023-05-15 09:0' } })
+ expect(
+ screen.getByTestId('data-table-column-filter-custom-Event date')
+ ).toBeInTheDocument()
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ filter: '2023-05-15 09:0',
+ })
+ })
+
+ test('stays shown and keeps live-applying even when the typed text exactly matches a tree node prefix (e.g. a full year)', () => {
+ const { store } = renderDateGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: '202' } })
+ fireEvent.change(getInput(), { target: { value: '2023' } })
+ expect(
+ screen.getByTestId('data-table-column-filter-custom-Event date')
+ ).toBeInTheDocument()
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'eventdate',
+ filter: '2023',
+ })
+ })
+})
+
+describe('DateGroupFilterInput - granularity variants', () => {
+ test('TYPE_DATE stops the Y/M/D hierarchy at the day level: real values (not hour buckets) appear under it, formatted like the column', () => {
+ renderDateGroupFilter({
+ type: TYPE_DATE,
+ options: [
+ { value: '2023-05-15 09:00:00.0' },
+ { value: '2023-05-15 14:00:00.0' },
+ ],
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand 2023'))
+ fireEvent.click(screen.getByLabelText('Expand May'))
+ expect(screen.getByLabelText('15 Monday')).toBeInTheDocument()
+ expect(screen.queryByLabelText('09:00')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Expand 15 Monday'))
+ expect(screen.getAllByLabelText('2023-05-15')).toHaveLength(2)
+ })
+
+ test('TYPE_TIME shows a flat hour-only list, no year/month/day levels, with real values under each hour', () => {
+ renderDateGroupFilter({
+ type: TYPE_TIME,
+ options: [{ value: '09:00:00' }, { value: '14:30:00' }],
+ })
+ openPopover()
+ expect(screen.getByLabelText('09:00')).toBeInTheDocument()
+ expect(screen.getByLabelText('14:00')).toBeInTheDocument()
+ expect(screen.queryByLabelText('2023')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByLabelText('Expand 09:00'))
+ expect(screen.getByLabelText('09:00:00')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx
index 6dbaf6f6c1..19fe751014 100644
--- a/src/components/datatable/__tests__/FilterInput.spec.jsx
+++ b/src/components/datatable/__tests__/FilterInput.spec.jsx
@@ -7,7 +7,11 @@ import {
DATA_FILTER_SET,
DATA_FILTER_CLEAR,
} from '../../../constants/actionTypes.js'
-import { SENTINEL_ANY_VALUE } from '../../../constants/dataTable.js'
+import {
+ SENTINEL_ANY_VALUE,
+ RENDERER_ORG_UNIT_NAME,
+ RENDERER_BOOLEAN,
+} from '../../../constants/dataTable.js'
import useOptionSet from '../../../hooks/useOptionSet.js'
import FilterInput from '../FilterInput.jsx'
@@ -240,6 +244,28 @@ describe('FilterInput multi-select path (no optionSetId)', () => {
openPopover('Legend')
expect(screen.getByLabelText('1000')).toBeInTheDocument()
})
+
+ test("renders a boolean column's raw values as Yes/No checkbox labels", () => {
+ const { store } = renderFilterInput({
+ dataKey: 'followUp',
+ name: 'Follow-up',
+ renderer: RENDERER_BOOLEAN,
+ options: [{ value: '1' }, { value: '0' }],
+ })
+ openPopover('Follow-up')
+ const yes = screen.getByLabelText('Yes')
+ const no = screen.getByLabelText('No')
+ expect(yes).toBeInTheDocument()
+ expect(no).toBeInTheDocument()
+ // The underlying dispatched filter value stays the raw stored string
+ fireEvent.click(yes)
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'followUp',
+ filter: ['1'],
+ })
+ })
})
describe('FilterInput multi-select path (optionSetId)', () => {
@@ -462,6 +488,83 @@ describe('FilterInput searchable popover — custom filter row', () => {
})
})
+describe('FilterInput searchable popover — org-unit-flavored plain-text column', () => {
+ const options = [{ value: 'facility1' }, { value: 'facility2' }]
+ const orgUnitIdToName = new Map([
+ ['facility1', 'Moyowa CHC'],
+ ['facility2', 'Tihun CHC'],
+ ])
+
+ test('resolves typed text to the matching raw value(s), not the raw typed text', () => {
+ const { store } = renderFilterInput({
+ dataKey: 'orgUnitOwn',
+ name: 'Org unit',
+ renderer: RENDERER_ORG_UNIT_NAME,
+ options,
+ orgUnitIdToName,
+ })
+ openPopover('Org unit')
+ fireEvent.change(getInput('Org unit'), {
+ target: { value: 'Moyowa' },
+ })
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'orgUnitOwn',
+ filter: {
+ values: ['facility1'],
+ searchDerived: true,
+ searchText: 'Moyowa',
+ },
+ })
+ })
+
+ test('resolves to an empty values list (matches nothing) rather than falling back to raw-text matching', () => {
+ const { store } = renderFilterInput({
+ dataKey: 'orgUnitOwn',
+ name: 'Org unit',
+ renderer: RENDERER_ORG_UNIT_NAME,
+ options,
+ orgUnitIdToName,
+ })
+ openPopover('Org unit')
+ fireEvent.change(getInput('Org unit'), {
+ target: { value: 'no such place' },
+ })
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'orgUnitOwn',
+ filter: {
+ values: [],
+ searchDerived: true,
+ searchText: 'no such place',
+ },
+ })
+ })
+
+ test('does not show any checkbox as checked while the search-derived filter is active', () => {
+ renderFilterInput(
+ {
+ dataKey: 'orgUnitOwn',
+ name: 'Org unit',
+ renderer: RENDERER_ORG_UNIT_NAME,
+ options,
+ orgUnitIdToName,
+ },
+ {
+ orgUnitOwn: {
+ values: ['facility1'],
+ searchDerived: true,
+ searchText: 'Moyowa',
+ },
+ }
+ )
+ openPopover('Org unit')
+ expect(screen.getByLabelText('Moyowa CHC')).not.toBeChecked()
+ })
+})
+
describe('FilterInput searchable popover — keyboard behavior', () => {
const options = [{ value: 'High' }, { value: 'Low' }]
diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx
new file mode 100644
index 0000000000..813687c49a
--- /dev/null
+++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx
@@ -0,0 +1,310 @@
+import { render, fireEvent, screen } from '@testing-library/react'
+import React from 'react'
+import { Provider } from 'react-redux'
+import { VirtuosoMockContext } from 'react-virtuoso'
+import configureMockStore from 'redux-mock-store'
+import {
+ DATA_FILTER_SET,
+ DATA_FILTER_CLEAR,
+} from '../../../constants/actionTypes.js'
+import {
+ SENTINEL_ANY_VALUE,
+ SENTINEL_NO_VALUE,
+ ORG_UNIT_GROUPS_GRANULARITY,
+} from '../../../constants/dataTable.js'
+import OrgUnitGroupFilterInput from '../OrgUnitGroupFilterInput.jsx'
+
+const mockStore = configureMockStore()
+
+const ORG_UNIT_VALUES = [
+ { value: '/country1/region1/facility1' },
+ { value: '/country1/region2/facility2' },
+ { value: '/country2/facility3' },
+]
+
+const renderOrgUnitGroupFilter = (props) => {
+ const store = mockStore({})
+ const result = render(
+
+
+
+
+
+ )
+ return { ...result, store }
+}
+
+const getInput = () =>
+ screen
+ .getByTestId('data-table-column-filter-search-Org unit')
+ .querySelector('input')
+
+const openPopover = () => fireEvent.focus(getInput())
+
+describe('OrgUnitGroupFilterInput - default (collapsed) tree', () => {
+ test('shows only root nodes by default', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ expect(screen.getByLabelText('country1')).toBeInTheDocument()
+ expect(screen.getByLabelText('country2')).toBeInTheDocument()
+ expect(screen.queryByLabelText('region1')).not.toBeInTheDocument()
+ })
+
+ test('expanding a root node reveals its children', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand country1'))
+ expect(screen.getByLabelText('region1')).toBeInTheDocument()
+ expect(screen.getByLabelText('region2')).toBeInTheDocument()
+ })
+
+ test('an org unit id is naturally a leaf - no separate terminal node beneath it', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand country2'))
+ expect(screen.getByLabelText('facility3')).toBeInTheDocument()
+ expect(
+ screen.queryByLabelText('Expand facility3')
+ ).not.toBeInTheDocument()
+ })
+
+ test('collapsing a root node hides its children again', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand country1'))
+ expect(screen.getByLabelText('region1')).toBeInTheDocument()
+ fireEvent.click(screen.getByLabelText('Collapse country1'))
+ expect(screen.queryByLabelText('region1')).not.toBeInTheDocument()
+ })
+})
+
+describe('OrgUnitGroupFilterInput - label resolution', () => {
+ test('shows the raw id as a placeholder label until the name resolves', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ expect(screen.getByLabelText('country1')).toBeInTheDocument()
+ })
+
+ test('shows the resolved name once idToName has it', () => {
+ renderOrgUnitGroupFilter({
+ idToName: new Map([['country1', 'Sierra Leone']]),
+ })
+ openPopover()
+ expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument()
+ expect(screen.queryByLabelText('country1')).not.toBeInTheDocument()
+ })
+})
+
+describe('OrgUnitGroupFilterInput - selection dispatches', () => {
+ test('checking a root node dispatches the full org-unit-group filter shape', () => {
+ const { store } = renderOrgUnitGroupFilter()
+ openPopover()
+ fireEvent.click(screen.getByLabelText('country1'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'orgUnitPath',
+ filter: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ },
+ })
+ })
+
+ test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => {
+ const { store } = renderOrgUnitGroupFilter({
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('country1'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_CLEAR,
+ layerId: 'layer1',
+ fieldId: 'orgUnitPath',
+ })
+ })
+})
+
+describe('OrgUnitGroupFilterInput - tri-state checkbox rendering', () => {
+ test('a root node is checked when its own prefix is selected', () => {
+ renderOrgUnitGroupFilter({
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ },
+ })
+ openPopover()
+ expect(screen.getByLabelText('country1')).toBeChecked()
+ })
+
+ test('a root node is indeterminate when only a descendant prefix is selected', () => {
+ renderOrgUnitGroupFilter({
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1/region1'],
+ },
+ })
+ openPopover()
+ const countryCheckbox = screen.getByLabelText('country1')
+ expect(countryCheckbox.indeterminate).toBe(true)
+ expect(countryCheckbox.checked).toBe(false)
+ })
+
+ test('a descendant is checked (not indeterminate) when its ancestor is selected', () => {
+ renderOrgUnitGroupFilter({
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Expand country1'))
+ const regionCheckbox = screen.getByLabelText('region1')
+ expect(regionCheckbox.checked).toBe(true)
+ expect(regionCheckbox.indeterminate).toBe(false)
+ })
+})
+
+describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => {
+ test('"No value" is only shown when the options include the not-set sentinel', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ expect(screen.queryByLabelText('No value')).not.toBeInTheDocument()
+ })
+
+ test('checking "Any value" dispatches the sentinel and clears prior selections', () => {
+ const { store } = renderOrgUnitGroupFilter({
+ options: [...ORG_UNIT_VALUES, { value: SENTINEL_NO_VALUE }],
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('Any value'))
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'orgUnitPath',
+ filter: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: [SENTINEL_ANY_VALUE],
+ },
+ })
+ })
+
+ test('clicking a tree node while "Any value" is active is a no-op', () => {
+ const { store } = renderOrgUnitGroupFilter({
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: [SENTINEL_ANY_VALUE],
+ },
+ })
+ openPopover()
+ fireEvent.click(screen.getByLabelText('country1'))
+ expect(store.getActions()).toEqual([])
+ })
+})
+
+describe('OrgUnitGroupFilterInput - search', () => {
+ test('typing narrows to matching branches and auto-expands their ancestors', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: 'region1' } })
+ expect(screen.getByLabelText('country1')).toBeInTheDocument()
+ expect(screen.getByLabelText('region1')).toBeInTheDocument()
+ expect(screen.queryByLabelText('country2')).not.toBeInTheDocument()
+ })
+
+ test('letters are allowed (unlike the date variant) since org unit ids/names are not purely numeric', () => {
+ renderOrgUnitGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: 'facility3' } })
+ expect(getInput()).toHaveValue('facility3')
+ expect(screen.getByLabelText('country2')).toBeInTheDocument()
+ })
+
+ test('also narrows by resolved name, not just raw id', () => {
+ renderOrgUnitGroupFilter({
+ idToName: new Map([['country1', 'Sierra Leone']]),
+ })
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: 'Sierra' } })
+ expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument()
+ expect(screen.queryByLabelText('country2')).not.toBeInTheDocument()
+ })
+
+ test('typing text with no tree match shows the custom filter row but clears rather than filtering by the raw id/path', () => {
+ const { store } = renderOrgUnitGroupFilter()
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: 'Nairobi' } })
+ expect(
+ screen.getByTestId('data-table-column-filter-custom-Org unit')
+ ).toBeInTheDocument()
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_CLEAR,
+ layerId: 'layer1',
+ fieldId: 'orgUnitPath',
+ })
+ expect(store.getActions()).not.toContainEqual(
+ expect.objectContaining({ type: DATA_FILTER_SET })
+ )
+ })
+
+ test('committing a name-matched custom filter dispatches the matched nodes’ prefixes, not a raw substring match against the id path', () => {
+ const { store } = renderOrgUnitGroupFilter({
+ idToName: new Map([['country1', 'Sierra Leone']]),
+ })
+ openPopover()
+ fireEvent.change(getInput(), { target: { value: 'Sierra' } })
+ expect(store.getActions()).toContainEqual({
+ type: DATA_FILTER_SET,
+ layerId: 'layer1',
+ fieldId: 'orgUnitPath',
+ filter: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ searchDerived: true,
+ searchText: 'Sierra',
+ },
+ })
+ })
+
+ test('a committed name-matched search narrows the table live but does not show any checkbox as checked - same as every other column’s typed "Contains" filter', () => {
+ renderOrgUnitGroupFilter({
+ idToName: new Map([['country1', 'Sierra Leone']]),
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ searchDerived: true,
+ searchText: 'Sierra',
+ },
+ })
+ openPopover()
+ expect(screen.getByLabelText('Sierra Leone').checked).toBe(false)
+ })
+
+ test('reopening after a committed search re-shows the typed text, not "N selected"', () => {
+ renderOrgUnitGroupFilter({
+ filterValue: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ searchDerived: true,
+ searchText: 'Sierra',
+ },
+ })
+ expect(getInput()).toHaveValue('Sierra')
+ })
+})
diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx
index 0f9c59ea97..83d85a83e7 100644
--- a/src/components/datatable/__tests__/useTableData.spec.jsx
+++ b/src/components/datatable/__tests__/useTableData.spec.jsx
@@ -6,14 +6,27 @@ import {
SENTINEL_SELECTED_ROW,
SENTINEL_NO_VALUE,
} from '../../../constants/dataTable.js'
+import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js'
import { useTableData } from '../useTableData.js'
jest.mock('../../map/MapApi.js', () => ({
loadEarthEngineWorker: jest.fn(),
}))
+jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}))
+
const mockStore = configureMockStore()
+beforeEach(() => {
+ useOrgUnitAncestorNames.mockReturnValue({
+ idToName: new Map(),
+ loading: false,
+ })
+})
+
describe('useTableData headers', () => {
test('gets headers and rows for facility layer', () => {
const store = {
@@ -48,17 +61,25 @@ describe('useTableData headers', () => {
)
const { headers, rows, isLoading } = result.current
- expect(headers).toHaveLength(3)
+ expect(headers).toHaveLength(5)
expect(headers).toMatchObject([
- { name: 'Name', dataKey: 'name', type: 'string' },
- { name: 'Id', dataKey: 'id', type: 'string' },
- { name: 'Type', dataKey: 'type', type: 'string' },
+ { name: 'Org unit Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' },
+ { name: 'Org unit level', dataKey: 'level', type: 'number' },
+ {
+ name: 'Org unit hierarchy',
+ dataKey: 'orgUnitPath',
+ type: 'orgUnit',
+ },
+ { name: 'Geometry type', dataKey: 'type', type: 'string' },
])
expect(rows).toHaveLength(1)
- expect(rows[0]).toHaveLength(3)
+ expect(rows[0]).toHaveLength(5)
expect(rows[0]).toMatchObject([
- { value: 'Facility 1', dataKey: 'name' },
{ value: 'facility-1', dataKey: 'id' },
+ { value: undefined, dataKey: 'orgUnitOwn' },
+ { value: null, dataKey: 'level' },
+ { value: undefined, dataKey: 'orgUnitPath' },
{ value: 'Point', dataKey: 'type' },
])
expect(isLoading).toBe(false)
@@ -200,19 +221,23 @@ describe('useTableData headers', () => {
const { headers, rows, isLoading } = result.current
expect(headers).toHaveLength(5)
expect(headers).toMatchObject([
- { name: 'Name', dataKey: 'name', type: 'string' },
- { name: 'Id', dataKey: 'id', type: 'string' },
- { name: 'Level', dataKey: 'level', type: 'number' },
- { name: 'Parent', dataKey: 'parentName', type: 'string' },
- { name: 'Type', dataKey: 'type', type: 'string' },
+ { name: 'Org unit Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' },
+ { name: 'Org unit level', dataKey: 'level', type: 'number' },
+ {
+ name: 'Org unit hierarchy',
+ dataKey: 'orgUnitPath',
+ type: 'orgUnit',
+ },
+ { name: 'Geometry type', dataKey: 'type', type: 'string' },
])
expect(rows).toHaveLength(1)
expect(rows[0]).toHaveLength(5)
expect(rows[0]).toMatchObject([
- { value: 'OrgUnitName 1', dataKey: 'name' },
{ value: 'orgunit-id-1', dataKey: 'id' },
+ { value: undefined, dataKey: 'orgUnitOwn' },
{ value: 3, dataKey: 'level' },
- { value: 'Bo', dataKey: 'parentName' },
+ { value: undefined, dataKey: 'orgUnitPath' },
{ value: 'MultiPolygon', dataKey: 'type' },
])
expect(isLoading).toBe(false)
@@ -258,12 +283,15 @@ describe('useTableData headers', () => {
const { headers, rows, isLoading } = result.current
expect(headers).toHaveLength(9)
expect(headers).toMatchObject([
- { name: 'Name', dataKey: 'name', type: 'string' },
- { name: 'Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' },
+ { name: 'Org unit level', dataKey: 'level', type: 'number' },
+ {
+ name: 'Org unit hierarchy',
+ dataKey: 'orgUnitPath',
+ type: 'orgUnit',
+ },
{ name: 'Value', dataKey: 'rawValue', type: 'number' },
- { name: 'Level', dataKey: 'level', type: 'number' },
- { name: 'Parent', dataKey: 'parentName', type: 'string' },
- { name: 'Type', dataKey: 'type', type: 'string' },
{ name: 'Legend', dataKey: 'legend', type: 'string' },
{ name: 'Range', dataKey: 'range', type: 'string' },
{
@@ -272,19 +300,20 @@ describe('useTableData headers', () => {
type: 'string',
renderer: 'rendercolor',
},
+ { name: 'Geometry type', dataKey: 'type', type: 'string' },
])
expect(rows).toHaveLength(1)
expect(rows[0]).toHaveLength(9)
expect(rows[0]).toMatchObject([
- { value: 'Ngelehun CHC', dataKey: 'name' },
{ value: 'thematicId-1', dataKey: 'id' },
- { value: 106.3, dataKey: 'rawValue' },
+ { value: undefined, dataKey: 'orgUnitOwn' },
{ value: 4, dataKey: 'level' },
- { value: 'Badjia', dataKey: 'parentName' },
- { value: 'Point', dataKey: 'type' },
+ { value: undefined, dataKey: 'orgUnitPath' },
+ { value: 106.3, dataKey: 'rawValue' },
{ value: 'Great', dataKey: 'legend' },
{ value: '90 – 120', dataKey: 'range' },
{ value: '#FFFFB2', dataKey: 'color' },
+ { value: 'Point', dataKey: 'type' },
])
expect(isLoading).toBe(false)
})
@@ -340,15 +369,15 @@ describe('useTableData headers', () => {
)
const { headers, rows } = result.current
expect(headers).toMatchObject([
- { name: 'Name', dataKey: 'name' },
- { name: 'Id', dataKey: 'id' },
+ { name: 'Org unit Id', dataKey: 'id' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn' },
+ { name: 'Org unit level', dataKey: 'level' },
+ { name: 'Org unit hierarchy', dataKey: 'orgUnitPath' },
{ name: 'Value (February 2023)', dataKey: 'rawValue' },
- { name: 'Level', dataKey: 'level' },
- { name: 'Parent', dataKey: 'parentName' },
- { name: 'Type', dataKey: 'type' },
{ name: 'Legend (February 2023)', dataKey: 'legend' },
{ name: 'Range (February 2023)', dataKey: 'range' },
{ name: 'Color (February 2023)', dataKey: 'color' },
+ { name: 'Geometry type', dataKey: 'type' },
])
expect(rows[0]).toEqual(
expect.arrayContaining([
@@ -457,11 +486,11 @@ describe('useTableData headers', () => {
)
const { headers, rows } = result.current
expect(headers).toMatchObject([
- { name: 'Name', dataKey: 'name' },
- { name: 'Id', dataKey: 'id' },
- { name: 'Level', dataKey: 'level' },
- { name: 'Parent', dataKey: 'parentName' },
- { name: 'Type', dataKey: 'type' },
+ { name: 'Org unit Id', dataKey: 'id' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn' },
+ { name: 'Org unit level', dataKey: 'level' },
+ { name: 'Org unit hierarchy', dataKey: 'orgUnitPath' },
+ { name: 'Geometry type', dataKey: 'type' },
{
name: 'Value (January 2023)',
dataKey: 'period_202301_rawValue',
@@ -546,10 +575,18 @@ describe('useTableData headers', () => {
}
)
const { headers, rows, isLoading } = result.current
- expect(headers).toHaveLength(7)
+ expect(headers).toHaveLength(10)
expect(headers).toMatchObject([
- { name: 'Org unit', dataKey: 'ouname', type: 'string' },
- { name: 'Id', dataKey: 'id', type: 'string' },
+ { name: 'Event Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' },
+ { name: 'Org unit level', dataKey: 'level', type: 'number' },
+ {
+ name: 'Org unit hierarchy',
+ dataKey: 'orgUnitPath',
+ type: 'orgUnit',
+ renderer: 'renderorgunit',
+ },
{
name: 'Event date',
dataKey: 'eventdate',
@@ -557,20 +594,23 @@ describe('useTableData headers', () => {
renderer: 'renderdate',
},
{
- name: 'Last updated on',
+ name: 'Last updated',
dataKey: 'lastupdated',
- type: 'date',
+ type: 'datetime',
renderer: 'renderdate',
},
{ name: 'Event status', dataKey: 'eventstatus', type: 'string' },
{ name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' },
- { name: 'Type', dataKey: 'type', type: 'string' },
+ { name: 'Geometry type', dataKey: 'type', type: 'string' },
])
expect(rows).toHaveLength(1)
- expect(rows[0]).toHaveLength(7)
+ expect(rows[0]).toHaveLength(10)
expect(rows[0]).toMatchObject([
- { value: 'Lumley Hospital', dataKey: 'ouname' },
{ value: 'a9712323629', dataKey: 'id' },
+ { value: undefined, dataKey: 'orgUnitId' },
+ { value: undefined, dataKey: 'orgUnitOwn' },
+ { value: null, dataKey: 'level' },
+ { value: undefined, dataKey: 'orgUnitPath' },
{ value: '2023-05-15 00:00:00.0', dataKey: 'eventdate' },
{ value: '2018-04-12 20:58:51.31', dataKey: 'lastupdated' },
{ value: 'ACTIVE', dataKey: 'eventstatus' },
@@ -681,20 +721,38 @@ describe('useTableData headers', () => {
)
const { headers, rows, isLoading } = result.current
- expect(headers).toHaveLength(4)
+ expect(headers).toHaveLength(11)
expect(headers).toMatchObject([
- { name: 'Id', dataKey: 'id', type: 'string' },
+ { name: 'Tracked entity Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' },
+ { name: 'Org unit level', dataKey: 'level', type: 'number' },
+ {
+ name: 'Org unit hierarchy',
+ dataKey: 'orgUnitPath',
+ type: 'orgUnit',
+ },
+ { name: 'Created', dataKey: 'createdAt', type: 'datetime' },
+ { name: 'Last updated', dataKey: 'updatedAt', type: 'datetime' },
{ name: 'First name', dataKey: 'w75KJ2mc4zz', type: 'string' },
{ name: 'Age', dataKey: 'zDhUuAYrxNC', type: 'number' },
{ name: 'Color', dataKey: 'color', type: 'string' },
+ { name: 'Geometry type', dataKey: 'type', type: 'string' },
])
expect(rows).toHaveLength(1)
- expect(rows[0]).toHaveLength(4)
+ expect(rows[0]).toHaveLength(11)
expect(rows[0]).toMatchObject([
{ value: 'PsgJS8BUxZd', dataKey: 'id' },
+ { value: undefined, dataKey: 'orgUnitId' },
+ { value: undefined, dataKey: 'orgUnitOwn' },
+ { value: null, dataKey: 'level' },
+ { value: undefined, dataKey: 'orgUnitPath' },
+ { value: undefined, dataKey: 'createdAt' },
+ { value: undefined, dataKey: 'updatedAt' },
{ value: 'Gabrielle', dataKey: 'w75KJ2mc4zz' },
{ value: 28, dataKey: 'zDhUuAYrxNC' },
{ value: '#e57200', dataKey: 'color' },
+ { value: undefined, dataKey: 'type' },
])
expect(isLoading).toBe(false)
})
@@ -1034,11 +1092,16 @@ describe('useTableData headers', () => {
)
const { headers, rows, isLoading } = result.current
- expect(headers).toHaveLength(5)
+ expect(headers).toHaveLength(7)
expect(headers).toMatchObject([
- { name: 'Name', dataKey: 'name', type: 'string' },
- { name: 'Id', dataKey: 'id', type: 'string' },
- { name: 'Type', dataKey: 'type', type: 'string' },
+ { name: 'Org unit Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' },
+ { name: 'Org unit level', dataKey: 'level', type: 'number' },
+ {
+ name: 'Org unit hierarchy',
+ dataKey: 'orgUnitPath',
+ type: 'orgUnit',
+ },
{
name: 'Sum Population',
dataKey: 'sum',
@@ -1051,17 +1114,20 @@ describe('useTableData headers', () => {
// roundFn: Function.prototype,
type: 'number',
},
+ { name: 'Geometry type', dataKey: 'type', type: 'string' },
])
- expect(headers[3].roundFn).toBeInstanceOf(Function)
expect(headers[4].roundFn).toBeInstanceOf(Function)
+ expect(headers[5].roundFn).toBeInstanceOf(Function)
expect(rows).toHaveLength(2)
- expect(rows[0]).toHaveLength(5)
+ expect(rows[0]).toHaveLength(7)
expect(rows[0]).toMatchObject([
- { value: 'Bo', dataKey: 'name' },
{ value: 'boOu', dataKey: 'id' },
- { value: 'Polygon', dataKey: 'type' },
+ { value: undefined, dataKey: 'orgUnitOwn' },
+ { value: null, dataKey: 'level' },
+ { value: undefined, dataKey: 'orgUnitPath' },
{ value: 851091, dataKey: 'sum' },
{ value: 47.35, dataKey: 'mean' },
+ { value: 'Polygon', dataKey: 'type' },
])
expect(isLoading).toBe(false)
})
@@ -1189,11 +1255,16 @@ describe('useTableData headers', () => {
)
const { headers, rows, isLoading } = result.current
- expect(headers).toHaveLength(5)
+ expect(headers).toHaveLength(7)
expect(headers).toMatchObject([
- { name: 'Name', dataKey: 'name', type: 'string' },
- { name: 'Id', dataKey: 'id', type: 'string' },
- { name: 'Type', dataKey: 'type', type: 'string' },
+ { name: 'Org unit Id', dataKey: 'id', type: 'string' },
+ { name: 'Org unit', dataKey: 'orgUnitOwn', type: 'string' },
+ { name: 'Org unit level', dataKey: 'level', type: 'number' },
+ {
+ name: 'Org unit hierarchy',
+ dataKey: 'orgUnitPath',
+ type: 'orgUnit',
+ },
{
name: 'Sum Population Age Groups',
dataKey: 'sum',
@@ -1206,17 +1277,20 @@ describe('useTableData headers', () => {
// roundFn: Function.prototype,
type: 'number',
},
+ { name: 'Geometry type', dataKey: 'type', type: 'string' },
])
- expect(headers[3].roundFn).toBeInstanceOf(Function)
expect(headers[4].roundFn).toBeInstanceOf(Function)
+ expect(headers[5].roundFn).toBeInstanceOf(Function)
expect(rows).toHaveLength(2)
- expect(rows[0]).toHaveLength(5)
+ expect(rows[0]).toHaveLength(7)
expect(rows[0]).toMatchObject([
- { value: 'Badija', dataKey: 'name' },
{ value: 'boOU', dataKey: 'id' },
- { value: 'Polygon', dataKey: 'type' },
+ { value: undefined, dataKey: 'orgUnitOwn' },
+ { value: null, dataKey: 'level' },
+ { value: undefined, dataKey: 'orgUnitPath' },
{ value: 2517, dataKey: 'sum' },
{ value: 3.976, dataKey: 'mean' },
+ { value: 'Polygon', dataKey: 'type' },
])
expect(isLoading).toBe(false)
})
@@ -1297,7 +1371,9 @@ describe('useTableData sorting', () => {
}
)
- const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column
+ const valueColumn = result.current.rows.map(
+ (row) => row.find((c) => c.dataKey === 'rawValue')?.value
+ )
expect(valueColumn).toEqual([5, 10, 15, null, null])
})
@@ -1319,7 +1395,9 @@ describe('useTableData sorting', () => {
}
)
- const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column
+ const valueColumn = result.current.rows.map(
+ (row) => row.find((c) => c.dataKey === 'rawValue')?.value
+ )
expect(valueColumn).toEqual([15, 10, 5, null, null])
})
@@ -1329,10 +1407,10 @@ describe('useTableData sorting', () => {
layer: 'thematic',
dataFilters: null,
data: [
- { id: '1', properties: { name: 'Zebra', value: 10 } },
- { id: '2', properties: { name: 'Apple', value: 5 } },
- { id: '3', properties: { name: undefined, value: 20 } },
- { id: '4', properties: { name: 'Banana', value: 15 } },
+ { id: '1', properties: { orgUnitOwn: 'Zebra', value: 10 } },
+ { id: '2', properties: { orgUnitOwn: 'Apple', value: 5 } },
+ { id: '3', properties: { orgUnitOwn: undefined, value: 20 } },
+ { id: '4', properties: { orgUnitOwn: 'Banana', value: 15 } },
],
}
@@ -1343,7 +1421,7 @@ describe('useTableData sorting', () => {
() =>
useTableData({
layer: layerWithStringData,
- sortField: 'name',
+ sortField: 'orgUnitOwn',
sortDirection: 'asc',
}),
{
@@ -1353,7 +1431,9 @@ describe('useTableData sorting', () => {
}
)
- const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column
+ const nameColumn = result.current.rows.map(
+ (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value
+ )
expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', undefined])
})
@@ -1363,10 +1443,10 @@ describe('useTableData sorting', () => {
layer: 'thematic',
dataFilters: null,
data: [
- { id: '1', properties: { name: 'Zebra', value: 10 } },
- { id: '2', properties: { name: 'Apple', value: 5 } },
- { id: '3', properties: { name: undefined, value: 20 } },
- { id: '4', properties: { name: 'Banana', value: 15 } },
+ { id: '1', properties: { orgUnitOwn: 'Zebra', value: 10 } },
+ { id: '2', properties: { orgUnitOwn: 'Apple', value: 5 } },
+ { id: '3', properties: { orgUnitOwn: undefined, value: 20 } },
+ { id: '4', properties: { orgUnitOwn: 'Banana', value: 15 } },
],
}
@@ -1377,7 +1457,7 @@ describe('useTableData sorting', () => {
() =>
useTableData({
layer: layerWithStringData,
- sortField: 'name',
+ sortField: 'orgUnitOwn',
sortDirection: 'desc',
}),
{
@@ -1387,7 +1467,9 @@ describe('useTableData sorting', () => {
}
)
- const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column
+ const nameColumn = result.current.rows.map(
+ (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value
+ )
expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', undefined])
})
@@ -1427,7 +1509,9 @@ describe('useTableData sorting', () => {
}
)
- const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column
+ const valueColumn = result.current.rows.map(
+ (row) => row.find((c) => c.dataKey === 'rawValue')?.value
+ )
expect(valueColumn).toEqual([5, 10, null, null])
})
@@ -1469,7 +1553,9 @@ describe('useTableData sorting', () => {
}
)
- const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column
+ const valueColumn = result.current.rows.map(
+ (row) => row.find((c) => c.dataKey === 'rawValue')?.value
+ )
expect(valueColumn).toEqual([null, null, null])
})
@@ -1479,9 +1565,15 @@ describe('useTableData sorting', () => {
layer: 'thematic',
dataFilters: null,
data: [
- { properties: { id: '1', name: 'Item C', rawValue: 3 } },
- { properties: { id: '2', name: 'Item A', rawValue: 1 } },
- { properties: { id: '3', name: 'Item B', rawValue: 2 } },
+ {
+ properties: { id: '1', orgUnitOwn: 'Item C', rawValue: 3 },
+ },
+ {
+ properties: { id: '2', orgUnitOwn: 'Item A', rawValue: 1 },
+ },
+ {
+ properties: { id: '3', orgUnitOwn: 'Item B', rawValue: 2 },
+ },
],
}
const store = { aggregations: {} }
@@ -1500,7 +1592,7 @@ describe('useTableData sorting', () => {
)
const names = result.current.rows.map(
- (row) => row.find((c) => c.dataKey === 'name')?.value
+ (row) => row.find((c) => c.dataKey === 'orgUnitOwn')?.value
)
expect(names).toEqual(['Item C', 'Item A', 'Item B'])
})
@@ -1605,8 +1697,8 @@ describe('useTableData showOnlyFeaturesInView', () => {
mapBounds: bounds,
})
expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'In view'
+ expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe(
+ 'inview'
)
})
@@ -1631,8 +1723,8 @@ describe('useTableData showOnlyFeaturesInView', () => {
mapBounds: bounds,
})
expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'In view'
+ expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe(
+ 'inview'
)
})
})
@@ -1677,9 +1769,7 @@ describe('useTableData selectionFilter', () => {
selectedIdSet: new Set(['a']),
})
expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'Item A'
- )
+ expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe('a')
})
test('includes only non-selected rows when filtered to "not-selected"', () => {
@@ -1691,9 +1781,7 @@ describe('useTableData selectionFilter', () => {
selectedIdSet: new Set(['a']),
})
expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'Item B'
- )
+ expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe('b')
})
test('includes all rows when both options are checked', () => {
@@ -1745,12 +1833,11 @@ describe('useTableData columnOptions', () => {
{
properties: {
id: 'ou1',
- name: 'Org unit 1',
+ orgUnitOwn: 'Org unit 1',
rawValue: 10,
legend: 'High',
range: '5 - 15',
level: 1,
- parentName: 'Country',
type: 'Point',
color: '#ff0000',
},
@@ -1758,12 +1845,11 @@ describe('useTableData columnOptions', () => {
{
properties: {
id: 'ou2',
- name: 'Org unit 2',
+ orgUnitOwn: 'Org unit 2',
rawValue: 20,
legend: 'Low',
range: '15 - 25',
level: 1,
- parentName: 'Country',
type: 'Point',
color: '#00ff00',
},
@@ -1778,7 +1864,7 @@ describe('useTableData columnOptions', () => {
{ value: 'Low' },
])
expect(current.columnOptions.type).toEqual([{ value: 'Point' }])
- expect(current.columnOptions.name).toEqual([
+ expect(current.columnOptions.orgUnitOwn).toEqual([
{ value: 'Org unit 1' },
{ value: 'Org unit 2' },
])
@@ -1786,7 +1872,6 @@ describe('useTableData columnOptions', () => {
{ value: 'ou1' },
{ value: 'ou2' },
])
- expect(current.columnOptions.parentName).toEqual([{ value: 'Country' }])
expect(current.columnOptions.rawValue).toEqual([
{ value: '10' },
{ value: '20' },
@@ -1878,27 +1963,24 @@ describe('useTableData columnOptions', () => {
{
properties: {
id: 'ou1',
- name: 'Org unit 1',
+ orgUnitOwn: 'Country',
level: 1,
- parentName: 'Country',
type: 'Point',
},
},
{
properties: {
id: 'ou2',
- name: 'Org unit 2',
+ orgUnitOwn: '',
level: 1,
- parentName: '',
type: 'Point',
},
},
{
properties: {
id: 'ou3',
- name: 'Org unit 3',
+ // orgUnitOwn omitted entirely (undefined)
level: 1,
- // parentName omitted entirely (undefined)
type: 'Point',
},
},
@@ -1907,7 +1989,7 @@ describe('useTableData columnOptions', () => {
const { current } = renderTableData(layer)
- expect(current.columnOptions.parentName).toEqual([
+ expect(current.columnOptions.orgUnitOwn).toEqual([
{ value: SENTINEL_NO_VALUE },
{ value: 'Country' },
])
@@ -1956,22 +2038,20 @@ describe('useTableData columnOptions', () => {
{
properties: {
id: 'ou1',
- name: 'Org unit 1',
+ orgUnitOwn: 'Org unit 1',
rawValue: 10,
legend: 'High',
level: 1,
- parentName: 'Country',
type: 'Point',
},
},
{
properties: {
id: 'ou2',
- name: 'Org unit 2',
+ orgUnitOwn: 'Org unit 2',
rawValue: 20,
legend: 'Low',
level: 1,
- parentName: 'Country',
type: 'Point',
},
},
@@ -1982,7 +2062,7 @@ describe('useTableData columnOptions', () => {
() =>
useTableData({
layer,
- sortField: 'name',
+ sortField: 'orgUnitOwn',
sortDirection: 'desc',
}),
{
@@ -1992,8 +2072,8 @@ describe('useTableData columnOptions', () => {
}
)
- // Sorted column (name, desc) is reversed to match...
- expect(result.current.columnOptions.name).toEqual([
+ // Sorted column (orgUnitOwn, desc) is reversed to match...
+ expect(result.current.columnOptions.orgUnitOwn).toEqual([
{ value: 'Org unit 2' },
{ value: 'Org unit 1' },
])
@@ -2016,8 +2096,20 @@ describe('useTableData globalSearch', () => {
layer: 'orgUnit',
dataFilters: null,
data: [
- { properties: { id: 'a', name: 'Kampala', parentName: 'Uganda' } },
- { properties: { id: 'b', name: 'Nairobi', parentName: 'Kenya' } },
+ {
+ properties: {
+ id: 'facility-a',
+ orgUnitPath: '/country1/facility-a',
+ orgUnitOwn: '/country1/facility-a',
+ },
+ },
+ {
+ properties: {
+ id: 'facility-b',
+ orgUnitPath: '/country1/facility-b',
+ orgUnitOwn: '/country1/facility-b',
+ },
+ },
],
}
@@ -2026,7 +2118,7 @@ describe('useTableData globalSearch', () => {
() =>
useTableData({
layer,
- sortField: 'name',
+ sortField: 'id',
sortDirection: 'asc',
globalSearch,
}),
@@ -2042,179 +2134,116 @@ describe('useTableData globalSearch', () => {
expect(current.rows).toHaveLength(2)
})
- test('matches case-insensitively across any string column', () => {
- const { current } = renderTableData('uganda')
- expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'Kampala'
- )
- })
-
- test('shows no rows when nothing matches', () => {
- const { current } = renderTableData('addis ababa')
- expect(current.rows).toHaveLength(0)
- })
-})
-
-describe('useTableData showOnlyFeaturesInView', () => {
- const store = { aggregations: {} }
- const bounds = [-10, -10, 10, 10]
-
- const layer = {
- id: 'test-layer',
- layer: 'orgUnit',
- dataFilters: null,
- data: [
- {
- id: 'inview',
- properties: { id: 'inview', name: 'In view' },
- geometry: { type: 'Point', coordinates: [0, 0] },
- },
- {
- id: 'outofview',
- properties: { id: 'outofview', name: 'Out of view' },
- geometry: { type: 'Point', coordinates: [50, 50] },
- },
- ],
- }
-
- const renderTableData = (props) =>
- renderHook(() => useTableData(props), {
- wrapper: ({ children }) => (
- {children}
- ),
- }).result
-
- test('includes all rows when the toggle is off', () => {
- const { current } = renderTableData({
- layer,
- sortField: 'name',
- sortDirection: 'asc',
- showOnlyFeaturesInView: false,
- mapBounds: bounds,
- })
- expect(current.rows).toHaveLength(2)
- })
-
- test('excludes features outside the current map bounds when the toggle is on', () => {
- const { current } = renderTableData({
- layer,
- sortField: 'name',
- sortDirection: 'asc',
- showOnlyFeaturesInView: true,
- mapBounds: bounds,
+ test('matches an org-unit-typed column (Org unit/Org unit hierarchy) by its resolved name - the raw stored value is an id/path, which never contains what a user types here', () => {
+ useOrgUnitAncestorNames.mockReturnValue({
+ idToName: new Map([
+ ['country1', 'Uganda'],
+ ['facility-a', 'Kampala'],
+ ['facility-b', 'Nairobi'],
+ ]),
+ loading: false,
})
+ const { current } = renderTableData('kampala')
expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'In view'
+ expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe(
+ 'facility-a'
)
})
- test('excludes features without geometry when the toggle is on', () => {
- const layerWithoutCoords = {
- ...layer,
- data: [layer.data[0]],
- dataWithoutCoords: [
+ test('also matches a custom ORGANISATION_UNIT-valued data element on an Event layer as plain text - the events analytics query always resolves it to a name server-side, so there is no id to look up', () => {
+ const eventLayer = {
+ layer: 'event',
+ dataFilters: null,
+ isExtended: true,
+ headers: [
{
- id: 'nogeom',
- properties: { id: 'nogeom', name: 'No geometry' },
- geometry: null,
+ name: 'c3d4e5f6a7b',
+ column: 'Referred by facility',
+ valueType: 'ORGANISATION_UNIT',
+ },
+ ],
+ data: [
+ {
+ properties: {
+ id: 'evt1',
+ type: 'Point',
+ eventdate: '2023-01-01',
+ c3d4e5f6a7b: 'Referral Hospital',
+ },
},
],
}
-
- const { current } = renderTableData({
- layer: layerWithoutCoords,
- sortField: 'name',
- sortDirection: 'asc',
- showOnlyFeaturesInView: true,
- mapBounds: bounds,
- })
- expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'In view'
+ const { result } = renderHook(
+ () =>
+ useTableData({
+ layer: eventLayer,
+ sortField: 'id',
+ sortDirection: 'asc',
+ globalSearch: 'referral',
+ }),
+ {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ }
)
+ expect(result.current.rows).toHaveLength(1)
+ expect(
+ result.current.rows[0].find((c) => c.dataKey === 'id').value
+ ).toBe('evt1')
})
-})
-
-describe('useTableData selectionFilter', () => {
- const store = { aggregations: {} }
-
- const layer = {
- id: 'test-layer',
- layer: 'orgUnit',
- dataFilters: null,
- data: [
- { id: 'a', properties: { id: 'a', name: 'Item A' } },
- { id: 'b', properties: { id: 'b', name: 'Item B' } },
- ],
- }
-
- const renderTableData = (props) =>
- renderHook(() => useTableData(props), {
- wrapper: ({ children }) => (
- {children}
- ),
- }).result
- test('includes all rows when no filter is applied', () => {
- const { current } = renderTableData({
- layer,
- sortField: 'name',
- sortDirection: 'asc',
- selectionFilter: [],
- selectedIdSet: new Set(['a']),
+ test('matches a custom ORGANISATION_UNIT-valued attribute on a Tracked entity layer only by its raw stored value, not its resolved name - only "Org unit hierarchy" gets name-aware global search', () => {
+ useOrgUnitAncestorNames.mockReturnValue({
+ idToName: new Map([['facility9', 'Referral Hospital']]),
+ loading: false,
})
- expect(current.rows).toHaveLength(2)
- })
+ const teiLayer = {
+ layer: 'trackedEntity',
+ dataFilters: null,
+ headers: [
+ {
+ name: 'Referred by facility',
+ dataKey: 'c3d4e5f6a7b',
+ valueType: 'ORGANISATION_UNIT',
+ },
+ ],
+ data: [
+ {
+ properties: {
+ id: 'tei1',
+ c3d4e5f6a7b: 'facility9',
+ },
+ },
+ ],
+ }
+ const renderTeiTableData = (globalSearch) =>
+ renderHook(
+ () =>
+ useTableData({
+ layer: teiLayer,
+ sortField: 'id',
+ sortDirection: 'asc',
+ globalSearch,
+ }),
+ {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ }
+ ).result
- test('includes only selected rows when filtered to "selected"', () => {
- const { current } = renderTableData({
- layer,
- sortField: 'name',
- sortDirection: 'asc',
- selectionFilter: ['selected'],
- selectedIdSet: new Set(['a']),
- })
- expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'Item A'
- )
- })
+ expect(renderTeiTableData('referral').current.rows).toHaveLength(0)
- test('includes only non-selected rows when filtered to "not-selected"', () => {
- const { current } = renderTableData({
- layer,
- sortField: 'name',
- sortDirection: 'asc',
- selectionFilter: ['not-selected'],
- selectedIdSet: new Set(['a']),
- })
+ const { current } = renderTeiTableData('facility9')
expect(current.rows).toHaveLength(1)
- expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
- 'Item B'
+ expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe(
+ 'tei1'
)
})
- test('includes all rows when both options are checked', () => {
- const { current } = renderTableData({
- layer,
- sortField: 'name',
- sortDirection: 'asc',
- selectionFilter: ['selected', 'not-selected'],
- selectedIdSet: new Set(['a']),
- })
- expect(current.rows).toHaveLength(2)
- })
-
- test('shows no rows when filtered to "selected" and nothing is selected', () => {
- const { current } = renderTableData({
- layer,
- sortField: 'name',
- sortDirection: 'asc',
- selectionFilter: ['selected'],
- selectedIdSet: new Set(),
- })
+ test('shows no rows when nothing matches', () => {
+ const { current } = renderTableData('addis ababa')
expect(current.rows).toHaveLength(0)
})
})
diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css
index 5f23ec511b..e0002b0700 100644
--- a/src/components/datatable/styles/BottomPanel.module.css
+++ b/src/components/datatable/styles/BottomPanel.module.css
@@ -40,54 +40,3 @@
background-color: var(--colors-grey300);
flex-shrink: 0;
}
-
-.clearFiltersButton:disabled {
- color: var(--colors-grey400);
- cursor: not-allowed;
-}
-
-.clearFiltersButton:disabled:hover {
- color: var(--colors-grey400);
- background-color: transparent;
-}
-
-.toggleButton.active {
- color: var(--colors-blue700);
- background-color: var(--colors-blue100);
-}
-
-.toggleButton.active:hover {
- background-color: var(--colors-blue200);
-}
-
-/* !important beats @dhis2/ui's own ColorPicker field margin. */
-.highlightColorPicker {
- margin-bottom: 0 !important;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- position: relative;
- top: -1px;
-}
-
-/* !important beats @dhis2/ui's own ColorPicker label size. */
-.highlightColorPicker label {
- box-sizing: border-box;
- overflow: hidden;
- min-width: 18px !important;
- min-height: 18px !important;
-}
-
-.globalSearch {
- flex: 0 1 160px;
- min-width: 90px;
-}
-
-.globalSearch > :global(div) {
- width: 100%;
-}
-
-.globalSearch :global(input.dense) {
- padding: 4px 6px;
- font-size: 11px;
-}
diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css
index 416b4a55d8..8bd794ee99 100644
--- a/src/components/datatable/styles/DataTable.module.css
+++ b/src/components/datatable/styles/DataTable.module.css
@@ -36,6 +36,7 @@ td.checkboxCell {
max-width: 76px;
text-align: center;
padding: 0;
+ padding-top: 3px;
vertical-align: middle;
}
diff --git a/src/components/datatable/useGroupFilterInput.js b/src/components/datatable/useGroupFilterInput.js
new file mode 100644
index 0000000000..4ccdc0d7ef
--- /dev/null
+++ b/src/components/datatable/useGroupFilterInput.js
@@ -0,0 +1,289 @@
+import { useCallback, useMemo, useRef, useState } from 'react'
+import { useDispatch } from 'react-redux'
+import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js'
+import {
+ SENTINEL_ANY_VALUE,
+ SENTINEL_NO_VALUE,
+} from '../../constants/dataTable.js'
+import {
+ getCyclicIndex,
+ getDisplayValue,
+ toOptionIndex,
+} from '../../util/filterInput.js'
+import { toggleAnyValue } from '../../util/filterSelection.js'
+import {
+ flattenVisibleNodes,
+ getNodeCheckState,
+ nodeMatchesOrHasMatch,
+ togglePrefix,
+} from '../../util/prefixTree.js'
+import { getDropdownPlacement } from './FilterDropdownPopover.jsx'
+
+const identity = (value) => value
+
+const useGroupFilterInput = ({
+ dataKey,
+ layerId,
+ filterValue,
+ options,
+ granularity,
+ buildTree,
+ getMatches,
+ parseFilterValue,
+ commitSearch,
+ sanitizeInput = identity,
+}) => {
+ const dispatch = useDispatch()
+ const anchorRef = useRef(null)
+ const listRef = useRef(null)
+ const [isOpen, setIsOpen] = useState(false)
+ const [searchText, setSearchText] = useState('')
+ const [expandedKeys, setExpandedKeys] = useState(() => new Set())
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
+
+ const { selectedPrefixes, appliedString } = parseFilterValue(filterValue)
+ const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE)
+ const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE)
+ const treePrefixes = selectedPrefixes.filter(
+ (p) => p !== SENTINEL_ANY_VALUE && p !== SENTINEL_NO_VALUE
+ )
+ const hasActiveFilter = selectedPrefixes.length > 0 || appliedString !== ''
+
+ const openPopover = () => {
+ setSearchText(appliedString)
+ setHighlightedIndex(-1)
+ setIsOpen(true)
+ }
+ const closePopover = () => setIsOpen(false)
+
+ const anchorRect = anchorRef.current?.getBoundingClientRect()
+ const { dropdownPlacement, dropdownSide, tooltipPlacement } =
+ getDropdownPlacement(anchorRect)
+
+ const applyValues = useCallback(
+ (nextPrefixes) =>
+ nextPrefixes.length
+ ? dispatch(
+ setDataFilter(layerId, dataKey, {
+ granularity,
+ prefixes: nextPrefixes,
+ })
+ )
+ : dispatch(clearDataFilter(layerId, dataKey)),
+ [dispatch, layerId, dataKey, granularity]
+ )
+
+ const hasNotSetOption = options.some(
+ ({ value }) => value === SENTINEL_NO_VALUE
+ )
+ const realValues = useMemo(
+ () =>
+ options
+ .filter(({ value }) => value !== SENTINEL_NO_VALUE)
+ .map((o) => o.value),
+ [options]
+ )
+
+ const tree = useMemo(() => buildTree(realValues), [buildTree, realValues])
+
+ const normalizedSearch = searchText.trim().toLowerCase()
+ const searchMatches = useMemo(
+ () => (normalizedSearch ? getMatches(tree, normalizedSearch) : null),
+ [tree, normalizedSearch, getMatches]
+ )
+ const effectiveExpanded = useMemo(
+ () =>
+ searchMatches
+ ? new Set([
+ ...expandedKeys,
+ ...searchMatches.expandedAncestorKeys,
+ ])
+ : expandedKeys,
+ [expandedKeys, searchMatches]
+ )
+
+ const visibleNodes = useMemo(() => {
+ const flattened = flattenVisibleNodes(tree, effectiveExpanded)
+ if (!searchMatches) {
+ return flattened
+ }
+ return flattened.filter(({ node }) =>
+ nodeMatchesOrHasMatch(node, searchMatches.matchedKeys)
+ )
+ }, [tree, effectiveExpanded, searchMatches])
+
+ const showCustomFilterRow = normalizedSearch !== ''
+ const totalCount = visibleNodes.length + (showCustomFilterRow ? 1 : 0)
+
+ const onToggleExpand = (key) =>
+ setExpandedKeys((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) {
+ next.delete(key)
+ } else {
+ next.add(key)
+ }
+ return next
+ })
+
+ const checkStateFor = (node) =>
+ anyValueActive ? 'checked' : getNodeCheckState(node, treePrefixes)
+
+ const onToggleNode = (node) => {
+ if (anyValueActive) {
+ return
+ }
+ const nextTreePrefixes = togglePrefix(treePrefixes, node)
+ applyValues(
+ notSetActive
+ ? [...nextTreePrefixes, SENTINEL_NO_VALUE]
+ : nextTreePrefixes
+ )
+ }
+
+ const onToggleAnyValue = () => applyValues(toggleAnyValue(selectedPrefixes))
+
+ const onToggleNotSet = () =>
+ applyValues(
+ notSetActive
+ ? selectedPrefixes.filter((p) => p !== SENTINEL_NO_VALUE)
+ : [...selectedPrefixes, SENTINEL_NO_VALUE]
+ )
+
+ const applyCustomFilter = (text) => {
+ if (!text) {
+ dispatch(clearDataFilter(layerId, dataKey))
+ return
+ }
+ commitSearch(text, { tree, dispatch, layerId, dataKey })
+ }
+
+ const onSearchChange = ({ value }) => {
+ const sanitized = sanitizeInput(value)
+ setSearchText(sanitized)
+ setHighlightedIndex(-1)
+
+ const trimmed = sanitized.trim()
+ if (trimmed === '') {
+ if (hasActiveFilter) {
+ dispatch(clearDataFilter(layerId, dataKey))
+ }
+ return
+ }
+
+ applyCustomFilter(trimmed)
+ }
+
+ const scrollHighlightedIntoView = (index) => {
+ const optionIndex = toOptionIndex(index, showCustomFilterRow)
+ if (optionIndex >= 0 && optionIndex < visibleNodes.length) {
+ listRef.current?.scrollToIndex({
+ index: optionIndex,
+ align: 'center',
+ })
+ }
+ }
+
+ const onEnterKey = () => {
+ if (highlightedIndex === -1) {
+ if (showCustomFilterRow) {
+ applyCustomFilter(searchText.trim())
+ }
+ return
+ }
+ if (showCustomFilterRow && highlightedIndex === 0) {
+ applyCustomFilter(searchText.trim())
+ return
+ }
+ const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow)
+ if (optionIndex >= 0 && optionIndex < visibleNodes.length) {
+ onToggleNode(visibleNodes[optionIndex].node)
+ }
+ }
+
+ const onSearchKeyDown = (_, event) => {
+ const optionIndex = toOptionIndex(highlightedIndex, showCustomFilterRow)
+ const { node } = visibleNodes[optionIndex] ?? {}
+ switch (event.key) {
+ case 'ArrowDown':
+ event.preventDefault()
+ setHighlightedIndex((i) => {
+ const next = getCyclicIndex(i, totalCount, 1)
+ scrollHighlightedIntoView(next)
+ return next
+ })
+ break
+ case 'ArrowUp':
+ event.preventDefault()
+ setHighlightedIndex((i) => {
+ const next = getCyclicIndex(i, totalCount, -1)
+ scrollHighlightedIntoView(next)
+ return next
+ })
+ break
+ case 'ArrowRight':
+ if (node?.children.length && !effectiveExpanded.has(node.key)) {
+ event.preventDefault()
+ onToggleExpand(node.key)
+ }
+ break
+ case 'ArrowLeft':
+ if (node?.children.length && effectiveExpanded.has(node.key)) {
+ event.preventDefault()
+ onToggleExpand(node.key)
+ }
+ break
+ case 'Enter':
+ event.preventDefault()
+ onEnterKey()
+ closePopover()
+ break
+ case 'Escape':
+ event.preventDefault()
+ closePopover()
+ break
+ default:
+ break
+ }
+ }
+
+ const displayValue = getDisplayValue({
+ isOpen,
+ searchText,
+ selected: selectedPrefixes,
+ appliedString,
+ })
+
+ return {
+ anchorRef,
+ listRef,
+ dropdownPlacement,
+ dropdownSide,
+ tooltipPlacement,
+ isOpen,
+ searchText,
+ highlightedIndex,
+ displayValue,
+ visibleNodes,
+ showCustomFilterRow,
+ anyValueActive,
+ notSetActive,
+ hasNotSetOption,
+ effectiveExpanded,
+ checkStateFor,
+ openPopover,
+ closePopover,
+ onSearchChange,
+ onSearchKeyDown,
+ onApplyCustomFilterClick: () => {
+ applyCustomFilter(searchText.trim())
+ closePopover()
+ },
+ onToggleExpand,
+ onToggleNode,
+ onToggleAnyValue,
+ onToggleNotSet,
+ }
+}
+
+export default useGroupFilterInput
diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js
index 131941cb7b..55f62b606d 100644
--- a/src/components/datatable/useTableData.js
+++ b/src/components/datatable/useTableData.js
@@ -4,6 +4,9 @@ import { useSelector } from 'react-redux'
import {
SENTINEL_SELECTED_ROW,
SORT_ASCENDING,
+ TYPE_ORG_UNIT,
+ RENDERER_ORG_UNIT,
+ RENDERER_ORG_UNIT_NAME,
} from '../../constants/dataTable.js'
import {
EVENT_LAYER,
@@ -16,7 +19,9 @@ import {
SELECTION_FILTER_SELECTED,
SELECTION_FILTER_NOT_SELECTED,
} from '../../constants/selection.js'
+import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js'
import { filterByGlobalSearch, filterData } from '../../util/filter.js'
+import { buildKnownOrgUnitNames } from '../../util/orgUnits.js'
import {
buildRowCells,
getColumnDistinctValues,
@@ -237,6 +242,28 @@ export const useTableData = ({
return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS
}, [columnDistinctValues, sortField, sortDirection])
+ const orgUnitPathValues = useMemo(
+ () =>
+ (headers ?? [])
+ .filter((h) =>
+ [RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME].includes(
+ h.renderer
+ )
+ )
+ .flatMap((h) =>
+ (columnOptions[h.dataKey] ?? []).map((o) => o.value)
+ ),
+ [headers, columnOptions]
+ )
+ const knownOrgUnitNames = useMemo(
+ () => buildKnownOrgUnitNames(dataWithAggregations),
+ [dataWithAggregations]
+ )
+ const { idToName: orgUnitIdToName } = useOrgUnitAncestorNames(
+ orgUnitPathValues,
+ knownOrgUnitNames
+ )
+
const rows = useMemo(() => {
if (errorCode.current) {
return null
@@ -253,11 +280,14 @@ export const useTableData = ({
const stringDataKeys = headers
.filter((h) => h.type === TYPE_STRING)
.map((h) => h.dataKey)
- filteredData = filterByGlobalSearch(
- filteredData,
- globalSearch,
- stringDataKeys
- )
+ const orgUnitDataKeys = headers
+ .filter((h) => h.type === TYPE_ORG_UNIT)
+ .map((h) => h.dataKey)
+ filteredData = filterByGlobalSearch(filteredData, globalSearch, {
+ stringDataKeys,
+ orgUnitDataKeys,
+ idToName: orgUnitIdToName,
+ })
}
if (selectionFilter?.length) {
@@ -275,9 +305,18 @@ export const useTableData = ({
}
}
- //sort
+ // Sort
+ const sortFieldRenderer = headers.find(
+ (h) => h.dataKey === sortField
+ )?.renderer
filteredData.sort((a, b) =>
- compareRows(a, b, { sortField, sortDirection, selectedIdSet })
+ compareRows(a, b, {
+ sortField,
+ sortDirection,
+ selectedIdSet,
+ orgUnitRenderer: sortFieldRenderer,
+ idToName: orgUnitIdToName,
+ })
)
return filteredData.map((item) => buildRowCells(item, headers))
@@ -292,6 +331,7 @@ export const useTableData = ({
sortDirection,
selectionFilter,
selectedIdSetDependency,
+ orgUnitIdToName,
])
// EE layers and event layers may be loading additional data
@@ -322,6 +362,7 @@ export const useTableData = ({
error: getErrorCodeText(errorCode.current),
totalCount,
filteredCount,
+ orgUnitIdToName,
columnOptions,
}
}
diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js
index 0bac15662d..ea8e695587 100644
--- a/src/constants/dataTable.js
+++ b/src/constants/dataTable.js
@@ -8,11 +8,21 @@ export const SORT_DESCENDING = 'desc'
export const RENDERER_COLOR = 'rendercolor'
export const RENDERER_ICON = 'rendericon'
export const RENDERER_DATE = 'renderdate'
+export const RENDERER_ORG_UNIT = 'renderorgunit'
+export const RENDERER_ORG_UNIT_NAME = 'renderorgunitname'
+export const RENDERER_BOOLEAN = 'renderboolean'
export const TYPE_NUMBER = 'number'
export const TYPE_STRING = 'string'
export const TYPE_DATE = 'date'
export const TYPE_DATETIME = 'datetime'
export const TYPE_TIME = 'time'
+export const TYPE_ORG_UNIT = 'orgUnit'
export const DATE_GROUPS_GRANULARITY = 'date-groups'
+export const ORG_UNIT_GROUPS_GRANULARITY = 'org-unit-groups'
+
+export const ORG_UNIT_PATH_DATA_KEY = 'orgUnitPath'
+export const ORG_UNIT_DATA_KEY = 'orgUnitOwn'
+export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId'
+export const ORG_UNIT_LEVEL_DATA_KEY = 'level'
diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js
new file mode 100644
index 0000000000..66d542c49f
--- /dev/null
+++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js
@@ -0,0 +1,129 @@
+import { renderHook, waitFor } from '@testing-library/react'
+import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js'
+import useOrgUnitAncestorNames, {
+ __resetOrgUnitNameSessionCacheForTests,
+} from '../useOrgUnitAncestorNames.js'
+
+jest.mock('@dhis2/app-runtime', () => ({
+ useDataEngine: () => mockEngine,
+}))
+const mockEngine = {}
+
+jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({
+ useCachedData: () => ({ nameProperty: 'displayShortName' }),
+}))
+
+jest.mock('../../util/orgUnits.js', () => ({
+ fetchOrgUnitPathDetails: jest.fn(),
+}))
+
+beforeEach(() => {
+ fetchOrgUnitPathDetails.mockReset()
+ __resetOrgUnitNameSessionCacheForTests()
+})
+
+describe('useOrgUnitAncestorNames', () => {
+ it('does not fetch when there are no path values', () => {
+ renderHook(() => useOrgUnitAncestorNames([]))
+ expect(fetchOrgUnitPathDetails).not.toHaveBeenCalled()
+ })
+
+ it('fetches once with the distinct ancestor ids extracted from every path value', async () => {
+ fetchOrgUnitPathDetails.mockResolvedValue({})
+ renderHook(() =>
+ useOrgUnitAncestorNames([
+ '/country1/region1/facility1',
+ '/country1/region2/facility2',
+ ])
+ )
+ await waitFor(() => {
+ expect(fetchOrgUnitPathDetails).toHaveBeenCalledTimes(1)
+ })
+ expect(fetchOrgUnitPathDetails).toHaveBeenCalledWith(
+ {},
+ expect.arrayContaining([
+ 'country1',
+ 'region1',
+ 'facility1',
+ 'region2',
+ 'facility2',
+ ]),
+ 'displayShortName'
+ )
+ })
+
+ it('transitions from loading to a resolved idToName map', async () => {
+ fetchOrgUnitPathDetails.mockResolvedValue({
+ country1: { name: 'Sierra Leone', level: 1 },
+ })
+ const { result } = renderHook(() =>
+ useOrgUnitAncestorNames(['/country1'])
+ )
+ expect(result.current.loading).toBe(true)
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false)
+ })
+ expect(result.current.idToName.get('country1')).toBe('Sierra Leone')
+ })
+
+ it('does not fetch ids that are present in the seed map, and returns them merged into idToName immediately', async () => {
+ fetchOrgUnitPathDetails.mockResolvedValue({
+ region1: { name: 'Region 1', level: 2 },
+ facility1: { name: 'Facility 1', level: 3 },
+ })
+ const knownIdToName = new Map([['country1', 'Sierra Leone']])
+ const { result } = renderHook(() =>
+ useOrgUnitAncestorNames(
+ ['/country1/region1/facility1'],
+ knownIdToName
+ )
+ )
+
+ expect(fetchOrgUnitPathDetails).toHaveBeenCalledWith(
+ {},
+ expect.arrayContaining(['region1', 'facility1']),
+ 'displayShortName'
+ )
+ const [, fetchedIds] = fetchOrgUnitPathDetails.mock.calls[0]
+ expect(fetchedIds).not.toContain('country1')
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false)
+ })
+ expect(result.current.idToName.get('country1')).toBe('Sierra Leone')
+ expect(result.current.idToName.get('region1')).toBe('Region 1')
+ })
+
+ it('skips the fetch entirely when every id is already known', () => {
+ const knownIdToName = new Map([['country1', 'Sierra Leone']])
+ const { result } = renderHook(() =>
+ useOrgUnitAncestorNames(['/country1'], knownIdToName)
+ )
+
+ expect(fetchOrgUnitPathDetails).not.toHaveBeenCalled()
+ expect(result.current.loading).toBe(false)
+ expect(result.current.idToName.get('country1')).toBe('Sierra Leone')
+ })
+
+ it('reuses a name across separate hook mounts, from the session cache', async () => {
+ fetchOrgUnitPathDetails.mockResolvedValue({
+ country1: { name: 'Sierra Leone', level: 1 },
+ })
+
+ const first = renderHook(() => useOrgUnitAncestorNames(['/country1']))
+ await waitFor(() => {
+ expect(first.result.current.loading).toBe(false)
+ })
+ first.unmount()
+
+ const second = renderHook(() => useOrgUnitAncestorNames(['/country1']))
+ await waitFor(() => {
+ expect(second.result.current.idToName.get('country1')).toBe(
+ 'Sierra Leone'
+ )
+ })
+
+ expect(fetchOrgUnitPathDetails).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js
new file mode 100644
index 0000000000..cad33a0f16
--- /dev/null
+++ b/src/hooks/useOrgUnitAncestorNames.js
@@ -0,0 +1,84 @@
+import { useDataEngine } from '@dhis2/app-runtime'
+import { useEffect, useMemo, useState } from 'react'
+import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx'
+import { fetchOrgUnitPathDetails } from '../util/orgUnits.js'
+
+const EMPTY_MAP = new Map()
+
+const orgUnitNameSessionCache = new Map()
+
+export const __resetOrgUnitNameSessionCacheForTests = () =>
+ orgUnitNameSessionCache.clear()
+
+const useOrgUnitAncestorNames = (
+ distinctPathValues,
+ knownIdToName = EMPTY_MAP
+) => {
+ const engine = useDataEngine()
+ const { nameProperty } = useCachedData()
+ const ids = useMemo(
+ () => [
+ ...new Set(
+ distinctPathValues.flatMap((path) =>
+ String(path).split('/').filter(Boolean)
+ )
+ ),
+ ],
+ [distinctPathValues]
+ )
+ const idsKey = ids.join(',')
+
+ const [idToName, setIdToName] = useState(new Map())
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (!ids.length) {
+ return
+ }
+
+ const buildMerged = () => {
+ const merged = new Map(knownIdToName)
+ ids.forEach((id) => {
+ if (!merged.has(id) && orgUnitNameSessionCache.has(id)) {
+ merged.set(id, orgUnitNameSessionCache.get(id))
+ }
+ })
+ return merged
+ }
+
+ const idsToFetch = ids.filter(
+ (id) => !knownIdToName.has(id) && !orgUnitNameSessionCache.has(id)
+ )
+
+ if (!idsToFetch.length) {
+ setIdToName(buildMerged())
+ setLoading(false)
+ return
+ }
+
+ let cancelled = false
+ setLoading(true)
+ fetchOrgUnitPathDetails(engine, idsToFetch, nameProperty).then(
+ (details) => {
+ if (cancelled) {
+ return
+ }
+ Object.entries(details).forEach(([id, d]) => {
+ orgUnitNameSessionCache.set(id, d.name)
+ })
+ setIdToName(buildMerged())
+ setLoading(false)
+ }
+ )
+ return () => {
+ cancelled = true
+ }
+ // idsKey is the stable, content-based dependency
+ // `ids` is a new array identity every render
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [engine, idsKey, nameProperty, knownIdToName])
+
+ return { idToName, loading }
+}
+
+export default useOrgUnitAncestorNames
diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js
index 7a58552fea..976864bef1 100644
--- a/src/loaders/__tests__/eventLoader.spec.js
+++ b/src/loaders/__tests__/eventLoader.spec.js
@@ -11,6 +11,7 @@ import {
ORG_UNITS_PATHS_QUERY,
} from '../../util/requests.js'
import eventLoader, {
+ attachOrgUnitPaths,
excludeEventsOutsideOrgUnits,
shouldUseServerCluster,
} from '../eventLoader.js'
@@ -734,6 +735,79 @@ describe('excludeEventsOutsideOrgUnits', () => {
})
})
+describe('attachOrgUnitPaths', () => {
+ test("attaches each event's org unit path via one bulk lookup over the distinct set of org-unit ids", async () => {
+ const engine = makeEngine({
+ orgUnitPathsById: {
+ fac1: '/country1/region1/fac1',
+ fac2: '/country1/region2/fac2',
+ },
+ })
+ const config = makeConfig(
+ [],
+ [
+ pointFeature('fac1', [5, 5]),
+ pointFeature('fac2', [50, 50]),
+ pointFeature('fac1', [6, 6]), // same org unit as the first
+ ]
+ )
+
+ await attachOrgUnitPaths({ config, engine })
+
+ expect(config.data.map((d) => d.properties.orgUnitPath)).toEqual([
+ '/country1/region1/fac1',
+ '/country1/region2/fac2',
+ '/country1/region1/fac1',
+ ])
+ // only the two distinct ids were requested, not one per event
+ const pathsQueryCall = engine.query.mock.calls.find(
+ ([query]) => query === ORG_UNITS_PATHS_QUERY
+ )
+ expect(pathsQueryCall[1].variables.ids.split(',')).toEqual([
+ 'fac1',
+ 'fac2',
+ ])
+ })
+
+ test('falls back to null when an org unit path could not be resolved', async () => {
+ const engine = makeEngine({ orgUnitPathsById: {} })
+ const config = makeConfig([], [pointFeature('fac1', [5, 5])])
+
+ await attachOrgUnitPaths({ config, engine })
+
+ expect(config.data[0].properties.orgUnitPath).toBeNull()
+ })
+
+ test('is a no-op when there is no data', async () => {
+ const engine = makeEngine({})
+ const config = makeConfig([], [])
+
+ await attachOrgUnitPaths({ config, engine })
+
+ expect(engine.query).not.toHaveBeenCalled()
+ })
+
+ test('also attaches org unit paths to dataWithoutCoords, so those rows get the same table columns as the main dataset', async () => {
+ const engine = makeEngine({
+ orgUnitPathsById: {
+ fac1: '/country1/region1/fac1',
+ fac3: '/country1/region3/fac3',
+ },
+ })
+ const config = makeConfig([], [pointFeature('fac1', [5, 5])])
+ config.dataWithoutCoords = [
+ { properties: { ou: 'fac3' } },
+ { properties: { ou: 'fac3' } },
+ ]
+
+ await attachOrgUnitPaths({ config, engine })
+
+ expect(
+ config.dataWithoutCoords.map((d) => d.properties.orgUnitPath)
+ ).toEqual(['/country1/region3/fac3', '/country1/region3/fac3'])
+ })
+})
+
describe('shouldUseServerCluster', () => {
const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1
diff --git a/src/loaders/__tests__/facilityLoader.spec.js b/src/loaders/__tests__/facilityLoader.spec.js
new file mode 100644
index 0000000000..b701a9c7fd
--- /dev/null
+++ b/src/loaders/__tests__/facilityLoader.spec.js
@@ -0,0 +1,126 @@
+import {
+ FIRST_DATA_ELEMENT_QUERY,
+ ORG_UNITS_COUNT_QUERY,
+ ORG_UNITS_PATHS_QUERY,
+} from '../../util/requests.js'
+import { applyMissingCoordsCount } from '../facilityLoader.js'
+
+const makeEngine = ({
+ missingOuIds = [],
+ ouNamesById = {},
+ orgUnitPathsById = {},
+} = {}) => ({
+ query: jest.fn((query, { variables } = {}) => {
+ if (query === FIRST_DATA_ELEMENT_QUERY) {
+ return Promise.resolve({
+ dataElements: { dataElements: [{ id: 'de1' }] },
+ })
+ }
+ if (query === ORG_UNITS_COUNT_QUERY) {
+ return Promise.resolve({
+ orgUnitsCount: {
+ metaData: {
+ dimensions: { ou: missingOuIds },
+ items: Object.fromEntries(
+ missingOuIds.map((id) => [
+ id,
+ { name: ouNamesById[id] ?? id },
+ ])
+ ),
+ },
+ },
+ })
+ }
+ if (query === ORG_UNITS_PATHS_QUERY) {
+ const requestedIds = variables.ids.split(',')
+ return Promise.resolve({
+ organisationUnits: {
+ organisationUnits: requestedIds
+ .filter((id) => orgUnitPathsById[id])
+ .map((id) => ({ id, path: orgUnitPathsById[id] })),
+ },
+ })
+ }
+ throw new Error('Unexpected query')
+ }),
+})
+
+describe('applyMissingCoordsCount', () => {
+ test('attaches org unit path, own name and level to facilities missing a point location', async () => {
+ const engine = makeEngine({
+ missingOuIds: ['fac2'],
+ ouNamesById: { fac2: 'Tihun CHC' },
+ orgUnitPathsById: { fac2: '/country1/region1/fac2' },
+ })
+ const config = {}
+ const legend = {}
+
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds: ['fac1', 'fac2'],
+ userId: 'user1',
+ features: [{ id: 'fac1' }],
+ legend,
+ alerts: [],
+ })
+
+ expect(legend.orgUnitsWithoutCoordinatesCount).toBe(1)
+ expect(legend.orgUnitsPointOnly).toBe(true)
+ expect(config.dataWithoutCoords).toEqual([
+ {
+ id: 'fac2',
+ properties: {
+ id: 'fac2',
+ name: 'Tihun CHC',
+ orgUnitId: 'fac2',
+ orgUnitPath: '/country1/region1/fac2',
+ orgUnitOwn: '/country1/region1/fac2',
+ level: 3,
+ },
+ },
+ ])
+ })
+
+ test('does not set dataWithoutCoords when nothing is missing', async () => {
+ const engine = makeEngine({ missingOuIds: [] })
+ const config = {}
+ const legend = {}
+
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds: ['fac1'],
+ userId: 'user1',
+ features: [{ id: 'fac1' }],
+ legend,
+ alerts: [],
+ })
+
+ expect(legend.orgUnitsWithoutCoordinatesCount).toBe(0)
+ expect(config.dataWithoutCoords).toBeUndefined()
+ })
+
+ test('pushes an alert and leaves dataWithoutCoords unset when the count query fails', async () => {
+ const engine = {
+ query: jest.fn().mockRejectedValue(new Error('Network error')),
+ }
+ const config = {}
+ const legend = {}
+ const alerts = []
+
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds: ['fac1'],
+ userId: 'user1',
+ features: [],
+ legend,
+ alerts,
+ })
+
+ expect(config.dataWithoutCoords).toBeUndefined()
+ expect(alerts).toEqual([
+ expect.objectContaining({
+ message: 'Could not count org units without a point location',
+ }),
+ ])
+ })
+})
diff --git a/src/loaders/__tests__/orgUnitLoader.spec.js b/src/loaders/__tests__/orgUnitLoader.spec.js
new file mode 100644
index 0000000000..a2082f58be
--- /dev/null
+++ b/src/loaders/__tests__/orgUnitLoader.spec.js
@@ -0,0 +1,125 @@
+import {
+ FIRST_DATA_ELEMENT_QUERY,
+ ORG_UNITS_COUNT_QUERY,
+ ORG_UNITS_PATHS_QUERY,
+} from '../../util/requests.js'
+import { applyMissingCoordsCount } from '../orgUnitLoader.js'
+
+const makeEngine = ({
+ missingOuIds = [],
+ ouNamesById = {},
+ orgUnitPathsById = {},
+} = {}) => ({
+ query: jest.fn((query, { variables } = {}) => {
+ if (query === FIRST_DATA_ELEMENT_QUERY) {
+ return Promise.resolve({
+ dataElements: { dataElements: [{ id: 'de1' }] },
+ })
+ }
+ if (query === ORG_UNITS_COUNT_QUERY) {
+ return Promise.resolve({
+ orgUnitsCount: {
+ metaData: {
+ dimensions: { ou: missingOuIds },
+ items: Object.fromEntries(
+ missingOuIds.map((id) => [
+ id,
+ { name: ouNamesById[id] ?? id },
+ ])
+ ),
+ },
+ },
+ })
+ }
+ if (query === ORG_UNITS_PATHS_QUERY) {
+ const requestedIds = variables.ids.split(',')
+ return Promise.resolve({
+ organisationUnits: {
+ organisationUnits: requestedIds
+ .filter((id) => orgUnitPathsById[id])
+ .map((id) => ({ id, path: orgUnitPathsById[id] })),
+ },
+ })
+ }
+ throw new Error('Unexpected query')
+ }),
+})
+
+describe('applyMissingCoordsCount', () => {
+ test('attaches org unit path, own name and level to org units missing coordinates', async () => {
+ const engine = makeEngine({
+ missingOuIds: ['ou2'],
+ ouNamesById: { ou2: 'District B' },
+ orgUnitPathsById: { ou2: '/country1/region1/ou2' },
+ })
+ const config = {}
+ const legend = {}
+
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds: ['ou1', 'ou2'],
+ userId: 'user1',
+ features: [{ id: 'ou1' }],
+ legend,
+ alerts: [],
+ })
+
+ expect(legend.orgUnitsWithoutCoordinatesCount).toBe(1)
+ expect(config.dataWithoutCoords).toEqual([
+ {
+ id: 'ou2',
+ properties: {
+ id: 'ou2',
+ name: 'District B',
+ orgUnitId: 'ou2',
+ orgUnitPath: '/country1/region1/ou2',
+ orgUnitOwn: '/country1/region1/ou2',
+ level: 3,
+ },
+ },
+ ])
+ })
+
+ test('does not set dataWithoutCoords when nothing is missing', async () => {
+ const engine = makeEngine({ missingOuIds: [] })
+ const config = {}
+ const legend = {}
+
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds: ['ou1'],
+ userId: 'user1',
+ features: [{ id: 'ou1' }],
+ legend,
+ alerts: [],
+ })
+
+ expect(legend.orgUnitsWithoutCoordinatesCount).toBe(0)
+ expect(config.dataWithoutCoords).toBeUndefined()
+ })
+
+ test('pushes an alert and leaves dataWithoutCoords unset when the count query fails', async () => {
+ const engine = {
+ query: jest.fn().mockRejectedValue(new Error('Network error')),
+ }
+ const config = {}
+ const legend = {}
+ const alerts = []
+
+ await applyMissingCoordsCount(config, {
+ engine,
+ orgUnitIds: ['ou1'],
+ userId: 'user1',
+ features: [],
+ legend,
+ alerts,
+ })
+
+ expect(config.dataWithoutCoords).toBeUndefined()
+ expect(alerts).toEqual([
+ expect.objectContaining({
+ message: 'Could not count org units without coordinates',
+ }),
+ ])
+ })
+})
diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js
index a9a69cceea..0968ca1327 100644
--- a/src/loaders/__tests__/trackedEntityLoader.spec.js
+++ b/src/loaders/__tests__/trackedEntityLoader.spec.js
@@ -43,6 +43,46 @@ describe('getAttributeProperties', () => {
]
expect(getAttributeProperties(attributes).ageUid).toBeUndefined()
})
+
+ it('resolves an option-set-coded value to its display name, like events analytics already does server-side', () => {
+ const attributes = [
+ { attribute: 'genderUid', value: 'M', valueType: 'TEXT' },
+ ]
+ const optionSetIdByAttribute = new Map([['genderUid', 'os1']])
+ const optionNamesByOptionSet = new Map([
+ [
+ 'os1',
+ new Map([
+ ['M', 'Male'],
+ ['F', 'Female'],
+ ]),
+ ],
+ ])
+ expect(
+ getAttributeProperties(
+ attributes,
+ optionSetIdByAttribute,
+ optionNamesByOptionSet
+ )
+ ).toEqual({ genderUid: 'Male' })
+ })
+
+ it('falls back to the raw code when no matching option name is found', () => {
+ const attributes = [
+ { attribute: 'genderUid', value: 'X', valueType: 'TEXT' },
+ ]
+ const optionSetIdByAttribute = new Map([['genderUid', 'os1']])
+ const optionNamesByOptionSet = new Map([
+ ['os1', new Map([['M', 'Male']])],
+ ])
+ expect(
+ getAttributeProperties(
+ attributes,
+ optionSetIdByAttribute,
+ optionNamesByOptionSet
+ )
+ ).toEqual({ genderUid: 'X' })
+ })
})
describe('getAttributeHeaders', () => {
@@ -73,14 +113,47 @@ describe('getAttributeHeaders', () => {
},
]
expect(getAttributeHeaders(instances)).toEqual([
- { name: 'First name', dataKey: 'w75KJ2mc4zz', valueType: 'TEXT' },
- { name: 'Last name', dataKey: 'zDhUuAYrxNC', valueType: 'TEXT' },
+ {
+ name: 'First name',
+ dataKey: 'w75KJ2mc4zz',
+ valueType: 'TEXT',
+ optionSet: null,
+ },
+ {
+ name: 'Last name',
+ dataKey: 'zDhUuAYrxNC',
+ valueType: 'TEXT',
+ optionSet: null,
+ },
])
})
it('returns an empty array when no instance has attributes', () => {
expect(getAttributeHeaders([{ attributes: [] }, {}])).toEqual([])
})
+
+ it('stamps the resolved optionSet id onto a header when optionSetIdByAttribute has it', () => {
+ const instances = [
+ {
+ attributes: [
+ {
+ attribute: 'genderUid',
+ displayName: 'Gender',
+ valueType: 'TEXT',
+ },
+ ],
+ },
+ ]
+ const optionSetIdByAttribute = new Map([['genderUid', 'os1']])
+ expect(getAttributeHeaders(instances, optionSetIdByAttribute)).toEqual([
+ {
+ name: 'Gender',
+ dataKey: 'genderUid',
+ valueType: 'TEXT',
+ optionSet: { id: 'os1' },
+ },
+ ])
+ })
})
describe('applyParsedConfig', () => {
@@ -153,9 +226,65 @@ describe('toGeoJson', () => {
properties: {
id: 'tei-1',
color: '#ff0000',
+ type: 'Point',
w75KJ2mc4zz: 'Gabrielle',
},
},
])
})
+
+ it("carries the instance's own org unit id through onto properties.orgUnit", () => {
+ const instances = [
+ {
+ id: 'tei-1',
+ geometry: { type: 'Point', coordinates: [1, 2] },
+ orgUnit: 'facility1',
+ attributes: [],
+ },
+ ]
+
+ const result = toGeoJson(instances, '#ff0000')
+
+ expect(result[0].properties.orgUnit).toBe('facility1')
+ })
+
+ it('carries createdAt/updatedAt through onto properties', () => {
+ const instances = [
+ {
+ id: 'tei-1',
+ geometry: { type: 'Point', coordinates: [1, 2] },
+ attributes: [],
+ createdAt: '2024-01-01T00:00:00.000',
+ updatedAt: '2024-06-15T12:30:00.000',
+ },
+ ]
+
+ const result = toGeoJson(instances, '#ff0000')
+
+ expect(result[0].properties.createdAt).toBe('2024-01-01T00:00:00.000')
+ expect(result[0].properties.updatedAt).toBe('2024-06-15T12:30:00.000')
+ })
+
+ it('resolves an option-set-coded attribute value to its display name when the resolution maps are given', () => {
+ const instances = [
+ {
+ id: 'tei-1',
+ geometry: { type: 'Point', coordinates: [1, 2] },
+ attributes: [
+ { attribute: 'genderUid', value: 'M', valueType: 'TEXT' },
+ ],
+ },
+ ]
+ const optionSetIdByAttribute = new Map([['genderUid', 'os1']])
+ const optionNamesByOptionSet = new Map([
+ ['os1', new Map([['M', 'Male']])],
+ ])
+
+ const result = toGeoJson(instances, '#ff0000', {
+ optionSetIdByAttribute,
+ optionNamesByOptionSet,
+ })
+
+ expect(result[0].properties.genderUid).toBe('Male')
+ })
})
diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js
index 19ee620108..2dd4126546 100644
--- a/src/loaders/earthEngineLoader.js
+++ b/src/loaders/earthEngineLoader.js
@@ -20,6 +20,8 @@ import { getRoundToPrecisionFn, formatWithSeparator } from '../util/numbers.js'
import {
getCoordinateField,
addAssociatedGeometries,
+ attachOrgUnitPaths,
+ getMissingOrgUnitId,
getOrgUnitsWithoutCoordsCount,
} from '../util/orgUnits.js'
import { GEOFEATURES_QUERY } from '../util/requests.js'
@@ -139,7 +141,11 @@ const earthEngineLoader = async ({
} else {
orgUnitsWithoutCoordsCount = result.count
if (result.count > 0) {
- config.dataWithoutCoords = result.missingOrgUnits
+ config.dataWithoutCoords = await attachOrgUnitPaths(
+ result.missingOrgUnits,
+ engine,
+ getMissingOrgUnitId
+ )
}
}
}
diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js
index 275ded1dbf..2a96c1bbf7 100644
--- a/src/loaders/eventLoader.js
+++ b/src/loaders/eventLoader.js
@@ -32,6 +32,7 @@ import {
} from '../util/geojson.js'
import { formatWithSeparator, parseWithSeparator } from '../util/numbers.js'
import {
+ attachOrgUnitPaths as attachOrgUnitPathsUtil,
fetchAssociatedGeometries,
fetchOrgUnitPaths,
getPolygonItems,
@@ -46,6 +47,23 @@ import { isValidUid } from '../util/uid.js'
const getEventOuId = (feature) =>
feature.properties?.ou ?? feature.properties?.['Organisation unit']
+export const attachOrgUnitPaths = async ({ config, engine }) => {
+ if (config.data?.length) {
+ config.data = await attachOrgUnitPathsUtil(
+ config.data,
+ engine,
+ getEventOuId
+ )
+ }
+ if (config.dataWithoutCoords?.length) {
+ config.dataWithoutCoords = await attachOrgUnitPathsUtil(
+ config.dataWithoutCoords,
+ engine,
+ getEventOuId
+ )
+ }
+}
+
// Expands USER_ORGUNIT/_CHILDREN/_GRANDCHILDREN into ids; [id] if literal.
const expandOrgUnitKeyword = (id, userOrgUnitIdsByKeyword) => {
if (id in userOrgUnitIdsByKeyword) {
@@ -334,6 +352,8 @@ const loadEventLayer = async ({
})
}
+ await attachOrgUnitPaths({ config, engine })
+
if (styleDataItem) {
await styleByDataItem(config, engine)
}
diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js
index ef8e07ed77..b8b66c49bd 100644
--- a/src/loaders/facilityLoader.js
+++ b/src/loaders/facilityLoader.js
@@ -12,6 +12,8 @@ import {
getPolygonItems,
getStyledOrgUnits,
getCoordinateField,
+ attachOrgUnitPaths,
+ getMissingOrgUnitId,
getOrgUnitsWithoutCoordsCount,
addGroupCountsToLegend,
loadGroupSetData,
@@ -19,7 +21,7 @@ import {
} from '../util/orgUnits.js'
import { GEOFEATURES_QUERY } from '../util/requests.js'
-const applyMissingCoordsCount = async (
+export const applyMissingCoordsCount = async (
config,
{ engine, orgUnitIds, userId, features, legend, alerts }
) => {
@@ -42,7 +44,11 @@ const applyMissingCoordsCount = async (
legend.orgUnitsWithoutCoordinatesCount = result.count
legend.orgUnitsPointOnly = true
if (result.count > 0) {
- config.dataWithoutCoords = result.missingOrgUnits
+ config.dataWithoutCoords = await attachOrgUnitPaths(
+ result.missingOrgUnits,
+ engine,
+ getMissingOrgUnitId
+ )
}
}
diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js
index 1df10a1527..9d32ea932c 100644
--- a/src/loaders/orgUnitLoader.js
+++ b/src/loaders/orgUnitLoader.js
@@ -11,10 +11,11 @@ import { parseJsonConfig } from '../util/config.js'
import { toGeoJson } from '../util/map.js'
import {
addAssociatedGeometries,
+ attachOrgUnitPaths,
getStyledOrgUnits,
getCoordinateField,
+ getMissingOrgUnitId,
getOrgUnitsWithoutCoordsCount,
- fetchOrgUnitDetails,
addGroupCountsToLegend,
addLevelCountsToLegend,
loadGroupSetData,
@@ -22,7 +23,7 @@ import {
} from '../util/orgUnits.js'
import { GEOFEATURES_QUERY } from '../util/requests.js'
-const applyMissingCoordsCount = async (
+export const applyMissingCoordsCount = async (
config,
{ engine, orgUnitIds, userId, features, legend, alerts }
) => {
@@ -42,18 +43,11 @@ const applyMissingCoordsCount = async (
}
legend.orgUnitsWithoutCoordinatesCount = result.count
if (result.count > 0) {
- const details = await fetchOrgUnitDetails(
+ config.dataWithoutCoords = await attachOrgUnitPaths(
+ result.missingOrgUnits,
engine,
- result.missingOrgUnits.map((o) => o.id)
+ getMissingOrgUnitId
)
- config.dataWithoutCoords = result.missingOrgUnits.map((ou) => ({
- ...ou,
- properties: {
- ...ou.properties,
- level: details[ou.id]?.level,
- parentName: details[ou.id]?.parentName,
- },
- }))
}
}
diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js
index fbe8feac80..b7410f2124 100644
--- a/src/loaders/thematicLoader.js
+++ b/src/loaders/thematicLoader.js
@@ -48,8 +48,9 @@ import {
import {
getCoordinateField,
addAssociatedGeometries,
+ attachOrgUnitPaths,
+ getMissingOrgUnitId,
getOrgUnitsWithoutCoordsCount,
- fetchOrgUnitDetails,
} from '../util/orgUnits.js'
import { LEGEND_SET_QUERY, GEOFEATURES_QUERY } from '../util/requests.js'
import { formatStartEndDate, getDateArray } from '../util/time.js'
@@ -200,26 +201,27 @@ const thematicLoader = async ({
if (!result.error) {
orgUnitsWithoutCoordsCount = result.count
if (result.count > 0) {
- const details = await fetchOrgUnitDetails(
+ const missingOrgUnitsWithPaths = await attachOrgUnitPaths(
+ result.missingOrgUnits,
engine,
- result.missingOrgUnits.map((o) => o.id)
+ getMissingOrgUnitId
+ )
+ config.dataWithoutCoords = missingOrgUnitsWithPaths.map(
+ (ou) => ({
+ ...ou,
+ properties: {
+ ...ou.properties,
+ rawValue: valueById[ou.id],
+ value:
+ valueById[ou.id] === undefined
+ ? undefined
+ : formatWithSeparator(
+ valueById[ou.id],
+ keyAnalysisDigitGroupSeparator
+ ),
+ },
+ })
)
- 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
- ),
- },
- }))
}
}
}
diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js
index b50e5b8e51..0311742dc6 100644
--- a/src/loaders/trackedEntityLoader.js
+++ b/src/loaders/trackedEntityLoader.js
@@ -19,10 +19,23 @@ import {
GEO_TYPE_FEATURE,
} from '../util/geojson.js'
import { parseWithSeparator } from '../util/numbers.js'
+import { attachOrgUnitPaths } from '../util/orgUnits.js'
+import { OPTION_SET_QUERY } from '../util/requests.js'
import { getDataWithRelationships } from '../util/teiRelationshipsParser.js'
import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js'
-
-const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes']
+import {
+ TRACKED_ENTITY_TRACKED_ENTITY_TYPE_ATTRIBUTES_QUERY,
+ TRACKED_ENTITY_PROGRAM_TRACKED_ENTITY_ATTRIBUTES_QUERY,
+} from '../util/trackedEntity.js'
+
+const fields = [
+ 'trackedEntity~rename(id)',
+ 'geometry',
+ 'attributes',
+ 'orgUnit',
+ 'createdAt',
+ 'updatedAt',
+]
// Valid geometry types for TEIs
const teiGeometryTypes = new Set([
@@ -103,25 +116,35 @@ const TRACKED_ENTITY_TYPES_QUERY = {
},
}
-export const getAttributeProperties = (attributes) =>
+export const getAttributeProperties = (
+ attributes,
+ optionSetIdByAttribute,
+ optionNamesByOptionSet
+) =>
Object.fromEntries(
- (attributes ?? []).map(({ attribute, value, valueType }) => [
- attribute,
- numberValueTypes.includes(valueType)
- ? parseWithSeparator(value)
- : value,
- ])
+ (attributes ?? []).map(({ attribute, value, valueType }) => {
+ if (numberValueTypes.includes(valueType)) {
+ return [attribute, parseWithSeparator(value)]
+ }
+ const optionSetId = optionSetIdByAttribute?.get(attribute)
+ const optionName = optionSetId
+ ? optionNamesByOptionSet?.get(optionSetId)?.get(value)
+ : undefined
+ return [attribute, optionName ?? value]
+ })
)
-export const getAttributeHeaders = (instances) => {
+export const getAttributeHeaders = (instances, optionSetIdByAttribute) => {
const headersByAttribute = new Map()
instances.forEach(({ attributes }) => {
;(attributes ?? []).forEach(({ attribute, displayName, valueType }) => {
if (!headersByAttribute.has(attribute)) {
+ const optionSetId = optionSetIdByAttribute?.get(attribute)
headersByAttribute.set(attribute, {
name: displayName,
dataKey: attribute,
valueType,
+ optionSet: optionSetId ? { id: optionSetId } : null,
})
}
})
@@ -131,16 +154,82 @@ export const getAttributeHeaders = (instances) => {
// The main tracked entity marker's own color is currently fixed still
// stamped here for when data table's Color column has real data
-export const toGeoJson = (instances, color) =>
- instances.map(({ id, geometry, attributes }) => ({
- type: GEO_TYPE_FEATURE,
- geometry,
- properties: {
- id,
- color,
- ...getAttributeProperties(attributes),
- },
- }))
+export const toGeoJson = (
+ instances,
+ color,
+ { optionSetIdByAttribute, optionNamesByOptionSet } = {}
+) =>
+ instances.map(
+ ({ id, geometry, attributes, orgUnit, createdAt, updatedAt }) => ({
+ type: GEO_TYPE_FEATURE,
+ geometry,
+ properties: {
+ id,
+ color,
+ orgUnit,
+ createdAt,
+ updatedAt,
+ type: geometry?.type,
+ ...getAttributeProperties(
+ attributes,
+ optionSetIdByAttribute,
+ optionNamesByOptionSet
+ ),
+ },
+ })
+ )
+
+const fetchOptionSetIdByAttribute = async (
+ engine,
+ { trackedEntityType, program }
+) => {
+ const { trackedEntityType: typeData } = await engine.query(
+ TRACKED_ENTITY_TRACKED_ENTITY_TYPE_ATTRIBUTES_QUERY,
+ { variables: { id: trackedEntityType.id, nameProperty: 'displayName' } }
+ )
+ let attributes = (typeData.trackedEntityTypeAttributes ?? []).map(
+ (a) => a.trackedEntityAttribute
+ )
+
+ if (program) {
+ const { program: programData } = await engine.query(
+ TRACKED_ENTITY_PROGRAM_TRACKED_ENTITY_ATTRIBUTES_QUERY,
+ { variables: { id: program.id, nameProperty: 'displayName' } }
+ )
+ const programAttributes = (
+ programData.programTrackedEntityAttributes ?? []
+ ).map((a) => a.trackedEntityAttribute)
+ attributes = [
+ ...attributes,
+ ...programAttributes.filter(
+ (a1) => !attributes.some((a2) => a2.id === a1.id)
+ ),
+ ]
+ }
+
+ return new Map(
+ attributes
+ .filter((a) => a.optionSet?.id)
+ .map((a) => [a.id, a.optionSet.id])
+ )
+}
+
+const fetchOptionNamesByOptionSet = async (engine, optionSetIds) => {
+ const entries = await Promise.all(
+ optionSetIds.map(async (id) => {
+ const { optionSet } = await engine.query(OPTION_SET_QUERY, {
+ variables: { id },
+ })
+ return [
+ id,
+ new Map(
+ (optionSet?.options ?? []).map((o) => [o.code, o.name])
+ ),
+ ]
+ })
+ )
+ return new Map(entries)
+}
export const applyParsedConfig = (config) => {
const { relationships, periodType, dataTableColumnConfig } =
@@ -352,7 +441,18 @@ const trackedEntityLoader = async ({
instance.geometry?.coordinates
)
- const headers = getAttributeHeaders(instances)
+ const optionSetIdByAttribute = instances.length
+ ? await fetchOptionSetIdByAttribute(engine, {
+ trackedEntityType,
+ program,
+ })
+ : new Map()
+
+ const headers = getAttributeHeaders(instances, optionSetIdByAttribute)
+
+ const optionNamesByOptionSet = await fetchOptionNamesByOptionSet(engine, [
+ ...new Set(headers.map((h) => h.optionSet?.id).filter(Boolean)),
+ ])
let alert
@@ -380,9 +480,18 @@ const trackedEntityLoader = async ({
legend,
}))
} else {
- data = toGeoJson(instances, pointColor)
+ data = toGeoJson(instances, pointColor, {
+ optionSetIdByAttribute,
+ optionNamesByOptionSet,
+ })
}
+ data = await attachOrgUnitPaths(
+ data,
+ engine,
+ (feature) => feature.properties.orgUnit
+ )
+
if (explanation) {
legend.explanation = [explanation]
}
diff --git a/src/util/__tests__/dateGroups.spec.js b/src/util/__tests__/dateGroups.spec.js
new file mode 100644
index 0000000000..7f09529e41
--- /dev/null
+++ b/src/util/__tests__/dateGroups.spec.js
@@ -0,0 +1,198 @@
+import {
+ TYPE_DATE,
+ TYPE_DATETIME,
+ TYPE_TIME,
+} from '../../constants/dataTable.js'
+import {
+ parseDateGroupKey,
+ buildDateGroupTree,
+ formatNodeLabel,
+} from '../dateGroups.js'
+
+describe('parseDateGroupKey', () => {
+ it('parses a space-delimited datetime, preserving the space as the hour prefix delimiter', () => {
+ expect(
+ parseDateGroupKey('2023-05-15 14:23:00.0', TYPE_DATETIME)
+ ).toEqual({
+ year: '2023',
+ month: '2023-05',
+ day: '2023-05-15',
+ hour: '2023-05-15 14',
+ })
+ })
+
+ it('parses a T-delimited datetime, preserving the T as the hour prefix delimiter', () => {
+ expect(parseDateGroupKey('2023-05-15T14:23:00', TYPE_DATETIME)).toEqual(
+ {
+ year: '2023',
+ month: '2023-05',
+ day: '2023-05-15',
+ hour: '2023-05-15T14',
+ }
+ )
+ })
+
+ it('returns a null hour when the raw value has no time component', () => {
+ expect(parseDateGroupKey('2023-05-15', TYPE_DATETIME)).toEqual({
+ year: '2023',
+ month: '2023-05',
+ day: '2023-05-15',
+ hour: null,
+ })
+ })
+
+ it('never includes an hour prefix for TYPE_DATE granularity, even if the raw value has a time part', () => {
+ expect(parseDateGroupKey('2023-05-15 14:23:00.0', TYPE_DATE)).toEqual({
+ year: '2023',
+ month: '2023-05',
+ day: '2023-05-15',
+ hour: null,
+ })
+ })
+
+ it('parses a bare hour for TYPE_TIME granularity', () => {
+ expect(parseDateGroupKey('14:23:00', TYPE_TIME)).toEqual({ hour: '14' })
+ })
+
+ it('returns null for unparseable values', () => {
+ expect(parseDateGroupKey('not-a-date', TYPE_DATETIME)).toBeNull()
+ expect(parseDateGroupKey('not-a-time', TYPE_TIME)).toBeNull()
+ })
+})
+
+describe('buildDateGroupTree', () => {
+ it('TYPE_DATE stops the Y/M/D hierarchy at the day level: its children are the real values, formatted like the column, not hour buckets', () => {
+ const values = ['2023-05-15 09:00:00.0', '2023-05-15 14:00:00.0']
+ const tree = buildDateGroupTree(values, TYPE_DATE)
+ expect(tree).toHaveLength(1) // one year
+ const [year] = tree
+ expect(year.children).toHaveLength(1) // one month
+ const [month] = year.children
+ expect(month.children).toHaveLength(1) // one day
+ const [day] = month.children
+ expect(day.children.every((c) => c.level === 'value')).toBe(true)
+ expect(day.children.map((c) => c.label)).toEqual([
+ '2023-05-15',
+ '2023-05-15',
+ ])
+ })
+
+ it('TYPE_DATETIME builds the full Year -> Month -> Day -> Hour -> value tree, and real values are formatted like the column', () => {
+ const values = [
+ '2023-05-15 09:00:00.0',
+ '2023-05-15 09:30:00.0',
+ '2023-05-15 14:00:00.0',
+ '2023-06-01 00:00:00.0',
+ ]
+ const tree = buildDateGroupTree(values, TYPE_DATETIME)
+ expect(tree).toHaveLength(1)
+ const [year] = tree
+ expect(year.key).toBe('2023')
+ expect(year.children.map((m) => m.key)).toEqual(['2023-05', '2023-06'])
+ const may = year.children[0]
+ expect(may.children).toHaveLength(1) // one day (15th)
+ const day15 = may.children[0]
+ expect(day15.children.map((h) => h.key)).toEqual([
+ '2023-05-15 09',
+ '2023-05-15 14',
+ ])
+ const hour09 = day15.children[0]
+ expect(hour09.children.map((v) => v.label)).toEqual([
+ '2023-05-15 09:00',
+ '2023-05-15 09:30',
+ ])
+ })
+
+ it('TYPE_TIME builds a flat Hour -> value tree, real values formatted verbatim (no date to format)', () => {
+ const tree = buildDateGroupTree(
+ ['09:00:00', '14:00:00', '09:30:00'],
+ TYPE_TIME
+ )
+ expect(tree.map((h) => h.key)).toEqual(['09', '14'])
+ expect(tree[0].children.map((v) => v.label)).toEqual([
+ '09:00:00',
+ '09:30:00',
+ ])
+ expect(tree[1].children.map((v) => v.label)).toEqual(['14:00:00'])
+ })
+
+ it("preserves the input order at every level, rather than forcing ascending - callers already order values to match the column's current sort direction", () => {
+ const ascendingValues = ['2023-01-01', '2023-05-15', '2024-01-01']
+ const ascending = buildDateGroupTree(ascendingValues, TYPE_DATE)
+ expect(ascending.map((y) => y.key)).toEqual(['2023', '2024'])
+ expect(ascending[0].children.map((m) => m.key)).toEqual([
+ '2023-01',
+ '2023-05',
+ ])
+
+ const descendingValues = ['2024-01-01', '2023-05-15', '2023-01-01']
+ const descending = buildDateGroupTree(descendingValues, TYPE_DATE)
+ expect(descending.map((y) => y.key)).toEqual(['2024', '2023'])
+ expect(descending[1].children.map((m) => m.key)).toEqual([
+ '2023-05',
+ '2023-01',
+ ])
+ })
+
+ it('buckets unparseable values as root-level leaf nodes instead of dropping them', () => {
+ const tree = buildDateGroupTree(['2023-05-15', 'garbage'], TYPE_DATE)
+ const leaf = tree.find((n) => n.level === 'leaf')
+ expect(leaf).toEqual({
+ key: 'garbage',
+ level: 'leaf',
+ label: 'garbage',
+ prefix: 'garbage',
+ children: [],
+ })
+ })
+})
+
+describe('formatNodeLabel', () => {
+ it('formats a year node verbatim', () => {
+ expect(formatNodeLabel({ level: 'year', key: '2023' }, 'en')).toBe(
+ '2023'
+ )
+ })
+
+ it('formats a month node as just a localized month name (no year - the ancestor year node already shows it)', () => {
+ expect(formatNodeLabel({ level: 'month', key: '2023-05' }, 'en')).toBe(
+ 'May'
+ )
+ })
+
+ it('formats a day node as the day number first, followed by the full weekday name', () => {
+ expect(formatNodeLabel({ level: 'day', key: '2023-05-15' }, 'en')).toBe(
+ '15 Monday'
+ )
+ })
+
+ it('zero-pads a single-digit day number', () => {
+ expect(formatNodeLabel({ level: 'day', key: '2023-05-01' }, 'en')).toBe(
+ '01 Monday'
+ )
+ })
+
+ it('formats a value node using its precomputed, column-matching label', () => {
+ expect(
+ formatNodeLabel(
+ { level: 'value', key: 'x', label: '2023-05-15 09:00' },
+ 'en'
+ )
+ ).toBe('2023-05-15 09:00')
+ })
+
+ it('formats an hour node (date-scoped or bare) as "HH:00"', () => {
+ expect(
+ formatNodeLabel({ level: 'hour', key: '2023-05-15 09' }, 'en')
+ ).toBe('09:00')
+ expect(formatNodeLabel({ level: 'hour', key: '09' }, 'en')).toBe(
+ '09:00'
+ )
+ })
+
+ it('falls back to the raw key for a leaf node', () => {
+ expect(formatNodeLabel({ level: 'leaf', key: 'garbage' }, 'en')).toBe(
+ 'garbage'
+ )
+ })
+})
diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js
index 5dc7069838..edcd9f3fd8 100644
--- a/src/util/__tests__/filter.spec.js
+++ b/src/util/__tests__/filter.spec.js
@@ -2,6 +2,7 @@ import {
SENTINEL_ANY_VALUE,
SENTINEL_NO_VALUE,
DATE_GROUPS_GRANULARITY,
+ ORG_UNIT_GROUPS_GRANULARITY,
} from '../../constants/dataTable.js'
import { filterByGlobalSearch, filterData } from '../filter.js'
@@ -203,6 +204,66 @@ describe('filterData', () => {
])
})
})
+
+ describe('org-unit-group filter ({ granularity, prefixes }) - same prefixGroupFilter matcher, different granularity', () => {
+ const data = [
+ { a: '/country1/region1/facility1' },
+ { a: '/country1/region2/facility2' },
+ { a: '/country2/region3/facility3' },
+ { a: null },
+ ]
+
+ it('matches every row under a selected ancestor prefix', () => {
+ const filters = {
+ a: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ },
+ }
+ expect(filterData(data, filters)).toEqual([
+ { a: '/country1/region1/facility1' },
+ { a: '/country1/region2/facility2' },
+ ])
+ })
+
+ it('matches only the selected leaf (facility) prefix', () => {
+ const filters = {
+ a: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1/region1/facility1'],
+ },
+ }
+ expect(filterData(data, filters)).toEqual([
+ { a: '/country1/region1/facility1' },
+ ])
+ })
+
+ it('SENTINEL_NO_VALUE only matches null/missing values', () => {
+ const filters = {
+ a: {
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: [SENTINEL_NO_VALUE],
+ },
+ }
+ expect(filterData(data, filters)).toEqual([{ a: null }])
+ })
+ })
+
+ describe('org-unit value filter ({ values, searchDerived, searchText }) - a committed free-text search on an org-unit-flavored plain-text column, resolved to matching raw values up front (see FilterInput.jsx)', () => {
+ const data = [{ a: 'moyowaId' }, { a: 'otherId' }, { a: null }]
+
+ it('matches rows whose raw value is in the resolved values list', () => {
+ const filters = {
+ a: { values: ['moyowaId'], searchDerived: true },
+ }
+ expect(filterData(data, filters)).toEqual([{ a: 'moyowaId' }])
+ })
+
+ it('matches no rows when nothing resolved (distinct from an empty checkbox array, which matches everything)', () => {
+ const filters = { a: { values: [], searchDerived: true } }
+ expect(filterData(data, filters)).toEqual([])
+ })
+ })
})
describe('filterByGlobalSearch', () => {
@@ -211,27 +272,28 @@ describe('filterByGlobalSearch', () => {
{ name: 'Entebbe Clinic', type: 'Clinic' },
{ name: 'Jinja Hospital', type: 'Hospital' },
]
+ const stringDataKeys = ['name', 'type']
it('returns the original data when the search string is empty', () => {
- expect(filterByGlobalSearch(data, '', ['name', 'type'])).toEqual(data)
- expect(filterByGlobalSearch(data, ' ', ['name', 'type'])).toEqual(
+ expect(filterByGlobalSearch(data, '', { stringDataKeys })).toEqual(data)
+ expect(filterByGlobalSearch(data, ' ', { stringDataKeys })).toEqual(
data
)
})
- it('returns the original data when there are no string data keys', () => {
- expect(filterByGlobalSearch(data, 'Kampala', [])).toEqual(data)
+ it('returns the original data when there are no string or org-unit data keys', () => {
+ expect(filterByGlobalSearch(data, 'Kampala', {})).toEqual(data)
})
it('matches case-insensitively across any of the given fields', () => {
- expect(filterByGlobalSearch(data, 'kampala', ['name', 'type'])).toEqual(
- [{ name: 'Kampala Hospital', type: 'Hospital' }]
- )
+ expect(
+ filterByGlobalSearch(data, 'kampala', { stringDataKeys })
+ ).toEqual([{ name: 'Kampala Hospital', type: 'Hospital' }])
})
it('matches rows where any field contains the search string', () => {
expect(
- filterByGlobalSearch(data, 'hospital', ['name', 'type'])
+ filterByGlobalSearch(data, 'hospital', { stringDataKeys })
).toEqual([
{ name: 'Kampala Hospital', type: 'Hospital' },
{ name: 'Jinja Hospital', type: 'Hospital' },
@@ -239,8 +301,26 @@ describe('filterByGlobalSearch', () => {
})
it('returns no rows when nothing matches', () => {
- expect(filterByGlobalSearch(data, 'nairobi', ['name', 'type'])).toEqual(
- []
- )
+ expect(
+ filterByGlobalSearch(data, 'nairobi', { stringDataKeys })
+ ).toEqual([])
+ })
+
+ it('also matches org-unit-typed columns by their resolved name, since the raw stored value is an id/path', () => {
+ const orgUnitData = [
+ { id: 'a', orgUnitPath: '/country1/region1/facility1' },
+ { id: 'b', orgUnitPath: '/country1/region2/facility2' },
+ ]
+ const idToName = new Map([
+ ['country1', 'Sierra Leone'],
+ ['region1', 'Bo'],
+ ['facility1', 'Bo Hospital'],
+ ])
+ expect(
+ filterByGlobalSearch(orgUnitData, 'bo hospital', {
+ orgUnitDataKeys: ['orgUnitPath'],
+ idToName,
+ })
+ ).toEqual([{ id: 'a', orgUnitPath: '/country1/region1/facility1' }])
})
})
diff --git a/src/util/__tests__/map.spec.js b/src/util/__tests__/map.spec.js
index 286e0f2648..39fd3bdede 100644
--- a/src/util/__tests__/map.spec.js
+++ b/src/util/__tests__/map.spec.js
@@ -1,4 +1,4 @@
-import { onFullscreenChange, resizeAndFitBounds } from '../map.js'
+import { onFullscreenChange, resizeAndFitBounds, toGeoJson } from '../map.js'
const bounds = [
[0, 0],
@@ -13,6 +13,61 @@ const createMockMap = (layersBounds = bounds) => ({
toggleScrollZoom: jest.fn(),
})
+describe('toGeoJson', () => {
+ it("builds orgUnitPath as the parent graph plus the org unit's own id", () => {
+ const [feature] = toGeoJson([
+ {
+ id: 'facility1',
+ co: '[10,20]',
+ ty: 1,
+ na: 'Facility 1',
+ pg: '/country1/region1',
+ pi: 'region1',
+ pn: 'Region 1',
+ le: 4,
+ },
+ ])
+ expect(feature.properties.orgUnitPath).toBe(
+ '/country1/region1/facility1'
+ )
+ })
+
+ it('falls back to just its own id when there is no parent graph (a root org unit)', () => {
+ const [feature] = toGeoJson([
+ {
+ id: 'country1',
+ co: '[10,20]',
+ ty: 1,
+ na: 'Country 1',
+ pg: '',
+ le: 1,
+ },
+ ])
+ expect(feature.properties.orgUnitPath).toBe('/country1')
+ })
+
+ it('always adds a leading slash, even though the real geoFeatures API returns pg without one (unlike organisationUnits.path)', () => {
+ const [feature] = toGeoJson([
+ {
+ id: 'facility1',
+ co: '[10,20]',
+ ty: 1,
+ na: 'Facility 1',
+ pg: 'country1/region1',
+ pi: 'region1',
+ pn: 'Region 1',
+ le: 4,
+ },
+ ])
+ expect(feature.properties.orgUnitPath).toBe(
+ '/country1/region1/facility1'
+ )
+ expect(feature.properties.orgUnitOwn).toBe(
+ '/country1/region1/facility1'
+ )
+ })
+})
+
describe('resizeAndFitBounds', () => {
it('resizes the map and fits bounds when layer bounds exist', () => {
const map = createMockMap()
diff --git a/src/util/__tests__/orgUnitGroups.spec.js b/src/util/__tests__/orgUnitGroups.spec.js
new file mode 100644
index 0000000000..e803df769a
--- /dev/null
+++ b/src/util/__tests__/orgUnitGroups.spec.js
@@ -0,0 +1,177 @@
+import {
+ buildOrgUnitGroupTree,
+ formatOrgUnitNodeLabel,
+ formatOrgUnitPathBreadcrumb,
+ getOrgUnitSearchMatches,
+} from '../orgUnitGroups.js'
+
+describe('buildOrgUnitGroupTree', () => {
+ it('builds a Country -> Region -> District -> Facility tree from full path values', () => {
+ const tree = buildOrgUnitGroupTree([
+ '/country1/region1/district1/facility1',
+ ])
+ expect(tree).toHaveLength(1)
+ const [country] = tree
+ expect(country).toMatchObject({
+ key: 'country1',
+ prefix: '/country1',
+ ouLevel: 1,
+ name: null,
+ })
+ expect(country.children).toHaveLength(1)
+ const [region] = country.children
+ expect(region).toMatchObject({
+ key: 'region1',
+ prefix: '/country1/region1',
+ ouLevel: 2,
+ })
+ const [district] = region.children
+ expect(district).toMatchObject({
+ key: 'district1',
+ prefix: '/country1/region1/district1',
+ ouLevel: 3,
+ })
+ const [facility] = district.children
+ expect(facility).toMatchObject({
+ key: 'facility1',
+ prefix: '/country1/region1/district1/facility1',
+ ouLevel: 4,
+ })
+ expect(facility.children).toEqual([])
+ })
+
+ it('handles a root-level org unit with a single-segment path', () => {
+ const tree = buildOrgUnitGroupTree(['/country1'])
+ expect(tree).toEqual([
+ {
+ key: 'country1',
+ prefix: '/country1',
+ ouLevel: 1,
+ name: null,
+ children: [],
+ },
+ ])
+ })
+
+ it('deduplicates a shared ancestor across multiple rows into one node', () => {
+ const tree = buildOrgUnitGroupTree([
+ '/country1/region1/facility1',
+ '/country1/region1/facility2',
+ '/country1/region2/facility3',
+ ])
+ expect(tree).toHaveLength(1) // one country
+ const [country] = tree
+ expect(country.children.map((r) => r.key)).toEqual([
+ 'region1',
+ 'region2',
+ ])
+ const region1 = country.children[0]
+ expect(region1.children.map((f) => f.key)).toEqual([
+ 'facility1',
+ 'facility2',
+ ])
+ })
+
+ it("preserves the input order at every level, rather than forcing ascending - callers already order pathValues to match the column's current sort direction", () => {
+ const ascending = buildOrgUnitGroupTree(['/b', '/a'])
+ expect(ascending.map((n) => n.key)).toEqual(['b', 'a'])
+
+ const descending = buildOrgUnitGroupTree([
+ '/country1/region2/facility1',
+ '/country1/region1/facility2',
+ ])
+ expect(descending.map((n) => n.key)).toEqual(['country1'])
+ expect(descending[0].children.map((n) => n.key)).toEqual([
+ 'region2',
+ 'region1',
+ ])
+ })
+})
+
+describe('formatOrgUnitNodeLabel', () => {
+ it('falls back to the raw id when the name has not resolved yet', () => {
+ expect(formatOrgUnitNodeLabel({ key: 'country1' }, new Map())).toBe(
+ 'country1'
+ )
+ expect(formatOrgUnitNodeLabel({ key: 'country1' }, undefined)).toBe(
+ 'country1'
+ )
+ })
+
+ it('uses the resolved name once present in the idToName map', () => {
+ const idToName = new Map([['country1', 'Sierra Leone']])
+ expect(formatOrgUnitNodeLabel({ key: 'country1' }, idToName)).toBe(
+ 'Sierra Leone'
+ )
+ })
+})
+
+describe('formatOrgUnitPathBreadcrumb', () => {
+ it('joins every resolved ancestor name with " / "', () => {
+ const idToName = new Map([
+ ['country1', 'Sierra Leone'],
+ ['region1', 'Bo'],
+ ['facility1', 'Bo Hospital'],
+ ])
+ expect(
+ formatOrgUnitPathBreadcrumb('/country1/region1/facility1', idToName)
+ ).toBe('Sierra Leone / Bo / Bo Hospital')
+ })
+
+ it('falls back to the raw id per-segment for names that have not resolved yet', () => {
+ const idToName = new Map([['country1', 'Sierra Leone']])
+ expect(
+ formatOrgUnitPathBreadcrumb('/country1/region1/facility1', idToName)
+ ).toBe('Sierra Leone / region1 / facility1')
+ })
+
+ it('falls back to raw ids entirely when idToName is undefined', () => {
+ expect(formatOrgUnitPathBreadcrumb('/country1/region1')).toBe(
+ 'country1 / region1'
+ )
+ })
+})
+
+describe('getOrgUnitSearchMatches', () => {
+ const tree = buildOrgUnitGroupTree([
+ '/country1/region1/facility1',
+ '/country1/region2/facility2',
+ ])
+ const idToName = new Map([
+ ['country1', 'Sierra Leone'],
+ ['region1', 'Bo'],
+ ['region2', 'Kailahun'],
+ ['facility1', 'Bo Hospital'],
+ ['facility2', 'Kailahun Clinic'],
+ ])
+
+ it('matches by raw id/prefix, same as the generic prefixTree.js matcher', () => {
+ const { matchedKeys } = getOrgUnitSearchMatches(
+ tree,
+ 'facility1',
+ idToName
+ )
+ expect(matchedKeys.has('facility1')).toBe(true)
+ expect(matchedKeys.has('facility2')).toBe(false)
+ })
+
+ it('also matches by resolved name, unlike the generic matcher', () => {
+ const { matchedKeys, expandedAncestorKeys } = getOrgUnitSearchMatches(
+ tree,
+ 'kailahun',
+ idToName
+ )
+ expect(matchedKeys.has('region2')).toBe(true)
+ expect(matchedKeys.has('facility2')).toBe(true)
+ expect(expandedAncestorKeys.has('country1')).toBe(true)
+ })
+
+ it('a node with no resolved name yet is still matchable by id', () => {
+ const { matchedKeys } = getOrgUnitSearchMatches(
+ tree,
+ 'country1',
+ new Map()
+ )
+ expect(matchedKeys.has('country1')).toBe(true)
+ })
+})
diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js
index 55362e6e8d..3673d49297 100644
--- a/src/util/__tests__/orgUnits.spec.js
+++ b/src/util/__tests__/orgUnits.spec.js
@@ -11,8 +11,10 @@ import {
fetchAndParseGroupSet,
loadGroupSetData,
getUserOrgUnitIdsByKeyword,
- fetchOrgUnitDetails,
fetchOrgUnitPaths,
+ fetchOrgUnitPathDetails,
+ attachOrgUnitPaths,
+ buildKnownOrgUnitNames,
} from '../orgUnits.js'
describe('getUserOrgUnitIdsByKeyword', () => {
@@ -61,15 +63,7 @@ describe('getUserOrgUnitIdsByKeyword', () => {
})
})
-describe('fetchOrgUnitDetails / fetchOrgUnitPaths error handling', () => {
- it('fetchOrgUnitDetails returns an empty object when the query fails', async () => {
- const engine = {
- query: jest.fn().mockRejectedValue(new Error('Network error')),
- }
- const result = await fetchOrgUnitDetails(engine, ['ou1'])
- expect(result).toEqual({})
- })
-
+describe('fetchOrgUnitPaths error handling', () => {
it('fetchOrgUnitPaths returns an empty array when the query fails', async () => {
const engine = {
query: jest.fn().mockRejectedValue(new Error('Network error')),
@@ -109,6 +103,160 @@ describe('fetchOrgUnitDetails / fetchOrgUnitPaths error handling', () => {
const result = await fetchOrgUnitPaths(engine, ['ou1'])
expect(result).toEqual([])
})
+
+ it('fetchOrgUnitPathDetails returns an empty object when the query fails', async () => {
+ const engine = {
+ query: jest.fn().mockRejectedValue(new Error('Network error')),
+ }
+ const result = await fetchOrgUnitPathDetails(engine, ['ou1'])
+ expect(result).toEqual({})
+ })
+
+ it('fetchOrgUnitPathDetails resolves ids to their name and level', async () => {
+ const engine = {
+ query: jest.fn().mockResolvedValue({
+ orgUnits: {
+ organisationUnits: [
+ { id: 'ou1', name: 'Sierra Leone', level: 1 },
+ { id: 'ou2', name: 'Bo', level: 2 },
+ ],
+ },
+ }),
+ }
+ const result = await fetchOrgUnitPathDetails(engine, ['ou1', 'ou2'])
+ expect(result).toEqual({
+ ou1: { name: 'Sierra Leone', level: 1 },
+ ou2: { name: 'Bo', level: 2 },
+ })
+ })
+
+ it('fetchOrgUnitPathDetails threads nameProperty through to the query fields', async () => {
+ const engine = {
+ query: jest.fn().mockResolvedValue({ orgUnits: {} }),
+ }
+ await fetchOrgUnitPathDetails(engine, ['ou1'], 'displayShortName')
+ const [, { variables }] = engine.query.mock.calls[0]
+ expect(variables.nameProperty).toBe('displayShortName')
+ })
+})
+
+describe('attachOrgUnitPaths', () => {
+ const getOuId = (feature) => feature.properties.ouId
+
+ it("attaches each feature's org unit path via one bulk lookup over the distinct set of ids", async () => {
+ const engine = {
+ query: jest.fn().mockResolvedValue({
+ organisationUnits: {
+ organisationUnits: [
+ { id: 'ou1', path: '/country1/ou1' },
+ { id: 'ou2', path: '/country1/ou2' },
+ ],
+ },
+ }),
+ }
+ const features = [
+ { properties: { ouId: 'ou1' } },
+ { properties: { ouId: 'ou2' } },
+ { properties: { ouId: 'ou1' } },
+ ]
+
+ const result = await attachOrgUnitPaths(features, engine, getOuId)
+
+ expect(result.map((f) => f.properties.orgUnitPath)).toEqual([
+ '/country1/ou1',
+ '/country1/ou2',
+ '/country1/ou1',
+ ])
+ const [, { variables }] = engine.query.mock.calls[0]
+ expect(variables.ids).toBe('ou1,ou2')
+ })
+
+ it('falls back to null when a path could not be resolved', async () => {
+ const engine = {
+ query: jest.fn().mockResolvedValue({
+ organisationUnits: { organisationUnits: [] },
+ }),
+ }
+ const result = await attachOrgUnitPaths(
+ [{ properties: { ouId: 'ou1' } }],
+ engine,
+ getOuId
+ )
+ expect(result[0].properties.orgUnitPath).toBeNull()
+ })
+
+ it('is a no-op that returns the input untouched when there are no features', async () => {
+ const engine = { query: jest.fn() }
+ const features = []
+ const result = await attachOrgUnitPaths(features, engine, getOuId)
+ expect(result).toBe(features)
+ expect(engine.query).not.toHaveBeenCalled()
+ })
+})
+
+describe('buildKnownOrgUnitNames', () => {
+ it("seeds a row's own id/name pair", () => {
+ const rows = [{ id: 'ou1', name: 'Facility 1' }]
+ expect(buildKnownOrgUnitNames(rows)).toEqual(
+ new Map([['ou1', 'Facility 1']])
+ )
+ })
+
+ it("seeds a row's parentId/parentName pair alongside its own", () => {
+ const rows = [
+ {
+ id: 'ou1',
+ name: 'Facility 1',
+ parentId: 'region1',
+ parentName: 'Region 1',
+ },
+ ]
+ expect(buildKnownOrgUnitNames(rows)).toEqual(
+ new Map([
+ ['ou1', 'Facility 1'],
+ ['region1', 'Region 1'],
+ ])
+ )
+ })
+
+ it('ignores an incomplete id/name or parentId/parentName pair', () => {
+ const rows = [
+ { id: 'ou1', name: null },
+ { id: null, name: 'Orphan name' },
+ { id: 'ou2', name: 'Facility 2', parentId: 'region1' },
+ ]
+ expect(buildKnownOrgUnitNames(rows)).toEqual(
+ new Map([['ou2', 'Facility 2']])
+ )
+ })
+
+ it('dedupes the same id across multiple rows', () => {
+ const rows = [
+ {
+ id: 'ou1',
+ name: 'Facility 1',
+ parentId: 'region1',
+ parentName: 'Region 1',
+ },
+ { id: 'region1', name: 'Region 1' },
+ ]
+ expect(buildKnownOrgUnitNames(rows)).toEqual(
+ new Map([
+ ['ou1', 'Facility 1'],
+ ['region1', 'Region 1'],
+ ])
+ )
+ })
+
+ it('contributes nothing for an Event/Tracked-Entity-shaped row (id is the event/TEI id, no name for the referenced org unit)', () => {
+ const rows = [{ id: 'event1', orgUnit: 'ou1' }]
+ expect(buildKnownOrgUnitNames(rows)).toEqual(new Map())
+ })
+
+ it('returns an empty map for no rows', () => {
+ expect(buildKnownOrgUnitNames([])).toEqual(new Map())
+ expect(buildKnownOrgUnitNames()).toEqual(new Map())
+ })
})
describe('getStyledOrgUnits', () => {
diff --git a/src/util/__tests__/prefixTree.spec.js b/src/util/__tests__/prefixTree.spec.js
new file mode 100644
index 0000000000..0282281c1f
--- /dev/null
+++ b/src/util/__tests__/prefixTree.spec.js
@@ -0,0 +1,145 @@
+import { TYPE_DATETIME } from '../../constants/dataTable.js'
+import { buildDateGroupTree } from '../dateGroups.js'
+import {
+ getNodeCheckState,
+ togglePrefix,
+ flattenVisibleNodes,
+ getSearchMatches,
+ nodeMatchesOrHasMatch,
+} from '../prefixTree.js'
+
+describe('getNodeCheckState', () => {
+ const dayNode = { prefix: '2023-05-15' }
+
+ it('is checked when the node itself is selected', () => {
+ expect(getNodeCheckState(dayNode, ['2023-05-15'])).toBe('checked')
+ })
+
+ it('is checked when an ancestor prefix is selected', () => {
+ expect(getNodeCheckState(dayNode, ['2023'])).toBe('checked')
+ })
+
+ it('is indeterminate when only a descendant prefix is selected', () => {
+ expect(getNodeCheckState(dayNode, ['2023-05-15 09'])).toBe(
+ 'indeterminate'
+ )
+ })
+
+ it('is unchecked otherwise', () => {
+ expect(getNodeCheckState(dayNode, ['2023-06-01'])).toBe('unchecked')
+ expect(getNodeCheckState(dayNode, [])).toBe('unchecked')
+ })
+})
+
+describe('togglePrefix', () => {
+ it('selects an unchecked node', () => {
+ expect(togglePrefix([], { prefix: '2023' })).toEqual(['2023'])
+ })
+
+ it('deselects a node that is checked via its own prefix', () => {
+ expect(togglePrefix(['2023-01', '2023'], { prefix: '2023' })).toEqual([
+ '2023-01',
+ ])
+ })
+
+ it('selecting a node drops now-redundant descendant prefixes', () => {
+ expect(
+ togglePrefix(['2023-01', '2023-02'], { prefix: '2023' })
+ ).toEqual(['2023'])
+ })
+
+ it('is a no-op when checked only via an already-selected ancestor', () => {
+ const selected = ['2023']
+ expect(togglePrefix(selected, { prefix: '2023-05-15 09' })).toBe(
+ selected
+ )
+ })
+
+ it('selecting an indeterminate node adds it without touching unrelated selections', () => {
+ expect(togglePrefix(['2024'], { prefix: '2023-05-15' })).toEqual([
+ '2024',
+ '2023-05-15',
+ ])
+ })
+})
+
+describe('flattenVisibleNodes', () => {
+ const tree = [
+ {
+ key: '2023',
+ children: [
+ {
+ key: '2023-05',
+ children: [{ key: '2023-05-15', children: [] }],
+ },
+ ],
+ },
+ { key: '2024', children: [] },
+ ]
+
+ it('shows only root nodes when nothing is expanded', () => {
+ expect(
+ flattenVisibleNodes(tree, new Set()).map((r) => r.node.key)
+ ).toEqual(['2023', '2024'])
+ })
+
+ it('shows children of an expanded node at depth + 1', () => {
+ const result = flattenVisibleNodes(tree, new Set(['2023']))
+ expect(result.map((r) => [r.node.key, r.depth])).toEqual([
+ ['2023', 0],
+ ['2023-05', 1],
+ ['2024', 0],
+ ])
+ })
+
+ it('recurses into nested expanded nodes', () => {
+ const result = flattenVisibleNodes(tree, new Set(['2023', '2023-05']))
+ expect(result.map((r) => r.node.key)).toEqual([
+ '2023',
+ '2023-05',
+ '2023-05-15',
+ '2024',
+ ])
+ })
+})
+
+describe('getSearchMatches / nodeMatchesOrHasMatch', () => {
+ const tree = buildDateGroupTree(
+ ['2023-05-15 09:00:00.0', '2024-01-01 00:00:00.0'],
+ TYPE_DATETIME
+ )
+
+ it('a year-number search matches every node whose raw prefix starts with that year, since a descendant prefix is always a literal extension of its ancestors', () => {
+ const { matchedKeys, expandedAncestorKeys } = getSearchMatches(
+ tree,
+ '2024'
+ )
+ expect(matchedKeys.has('2024')).toBe(true)
+ expect(matchedKeys.has('2024-01')).toBe(true)
+ expect(matchedKeys.has('2024-01-01 00:00:00.0')).toBe(true)
+ expect(matchedKeys.has('2023')).toBe(false)
+ // the value match's ancestors get force-expanded
+ expect(expandedAncestorKeys.has('2024')).toBe(true)
+ expect(expandedAncestorKeys.has('2024-01')).toBe(true)
+ expect(expandedAncestorKeys.has('2024-01-01')).toBe(true)
+ expect(expandedAncestorKeys.has('2024-01-01 00')).toBe(true)
+ })
+
+ it('matches a deep node by a longer numeric prefix and reports every ancestor key', () => {
+ const { matchedKeys, expandedAncestorKeys } = getSearchMatches(
+ tree,
+ '2023-05'
+ )
+ expect(matchedKeys.has('2023-05')).toBe(true)
+ expect(matchedKeys.has('2024-01')).toBe(false)
+ expect(expandedAncestorKeys.has('2023')).toBe(true)
+ })
+
+ it('nodeMatchesOrHasMatch is true for a match and for any ancestor of a match', () => {
+ const { matchedKeys } = getSearchMatches(tree, '2023-05')
+ const yearNode = tree.find((n) => n.key === '2023')
+ expect(nodeMatchesOrHasMatch(yearNode, matchedKeys)).toBe(true)
+ const otherYear = tree.find((n) => n.key === '2024')
+ expect(nodeMatchesOrHasMatch(otherYear, matchedKeys)).toBe(false)
+ })
+})
diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js
index b130fe4d24..a8d175a452 100644
--- a/src/util/__tests__/tableHeaders.spec.js
+++ b/src/util/__tests__/tableHeaders.spec.js
@@ -1,4 +1,8 @@
-import { RENDERER_DATE } from '../../constants/dataTable.js'
+import {
+ RENDERER_DATE,
+ RENDERER_ORG_UNIT,
+ RENDERER_BOOLEAN,
+} from '../../constants/dataTable.js'
import {
EVENT_LAYER,
THEMATIC_LAYER,
@@ -30,15 +34,15 @@ describe('getHeadersForLayer - thematic', () => {
isMultiPeriodThematic: false,
})
expect(dataKeys(result)).toEqual([
- 'name',
'id',
- 'rawValue',
+ 'orgUnitOwn',
'level',
- 'parentName',
- 'type',
+ 'orgUnitPath',
+ 'rawValue',
'legend',
'range',
'color',
+ 'type',
])
})
@@ -54,10 +58,10 @@ describe('getHeadersForLayer - thematic', () => {
})
expect(dataKeys(result)).toEqual(
expect.arrayContaining([
- 'name',
'id',
+ 'orgUnitOwn',
+ 'orgUnitPath',
'level',
- 'parentName',
'type',
'period_p1_rawValue',
'period_p2_rawValue',
@@ -96,7 +100,14 @@ describe('getHeadersForLayer - event', () => {
]
const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders })
expect(dataKeys(result)).toEqual(
- expect.arrayContaining(['ouname', 'id', 'eventdate', 'w75KJ2mc4zz'])
+ expect.arrayContaining([
+ 'id',
+ 'orgUnitId',
+ 'orgUnitOwn',
+ 'eventdate',
+ 'orgUnitPath',
+ 'w75KJ2mc4zz',
+ ])
)
expect(dataKeys(result)).not.toContain('not-a-uid')
const ageHeader = result.headers.find(
@@ -125,6 +136,16 @@ describe('getHeadersForLayer - event', () => {
valueType: 'TEXT',
optionSet: { id: 'os1' },
},
+ {
+ name: 'c3d4e5f6a7b',
+ column: 'Referred by facility',
+ valueType: 'ORGANISATION_UNIT',
+ },
+ {
+ name: 'd4e5f6a7b8c',
+ column: 'Follow-up',
+ valueType: 'BOOLEAN',
+ },
]
const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders })
const headerFor = (dataKey) =>
@@ -135,11 +156,29 @@ describe('getHeadersForLayer - event', () => {
expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME)
expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE)
expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING)
+ expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING)
+ expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING)
expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE)
expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE)
expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE)
expect(headerFor('a1b2c3d4e5f').renderer).toBe(RENDERER_DATE)
expect(headerFor('b2c3d4e5f6a').renderer).toBeUndefined()
+ expect(headerFor('c3d4e5f6a7b').renderer).toBe(RENDERER_ORG_UNIT)
+ expect(headerFor('d4e5f6a7b8c').renderer).toBe(RENDERER_BOOLEAN)
+ })
+
+ test('option-set-backed custom field carries the optionSet id onto the header', () => {
+ const layerHeaders = [
+ {
+ name: 'b2c3d4e5f6a',
+ column: 'Gender',
+ valueType: 'TEXT',
+ optionSet: { id: 'os1' },
+ },
+ ]
+ const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders })
+ const header = result.headers.find((h) => h.dataKey === 'b2c3d4e5f6a')
+ expect(header.optionSet).toEqual({ id: 'os1' })
})
test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => {
@@ -152,6 +191,25 @@ describe('getHeadersForLayer - event', () => {
expect(dataKeys(withBoundary)).toContain('ouBoundary')
})
+ test('does not duplicate the fixed "Last updated" column when the analytics response happens to include a same-named header ("lastupdated" coincidentally matches the 11-char isValidUid shape)', () => {
+ const layerHeaders = [
+ {
+ name: 'lastupdated',
+ column: 'Last updated on',
+ valueType: 'DATE',
+ },
+ ]
+ const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders })
+ const lastUpdatedHeaders = result.headers.filter(
+ (h) => h.dataKey === 'lastupdated'
+ )
+ expect(lastUpdatedHeaders).toHaveLength(1)
+ expect(lastUpdatedHeaders[0]).toMatchObject({
+ name: 'Last updated',
+ type: TYPE_DATETIME,
+ })
+ })
+
test('adds legend/range/color only when styled by a data item', () => {
const unstyled = getHeadersForLayer(EVENT_LAYER, { layerHeaders: [] })
const styled = getHeadersForLayer(EVENT_LAYER, {
@@ -172,11 +230,11 @@ describe('getHeadersForLayer - org unit / facility', () => {
})
expect(dataKeys(result)).toEqual(
expect.arrayContaining([
- 'name',
'id',
+ 'orgUnitOwn',
'level',
- 'parentName',
'type',
+ 'orgUnitPath',
'color',
'iconUrl',
])
@@ -188,7 +246,14 @@ describe('getHeadersForLayer - org unit / facility', () => {
const result = getHeadersForLayer(FACILITY_LAYER, {
data: [{ group: 'g1' }],
})
- expect(dataKeys(result)).toEqual(['name', 'id', 'type', 'group'])
+ expect(dataKeys(result)).toEqual([
+ 'id',
+ 'orgUnitOwn',
+ 'level',
+ 'orgUnitPath',
+ 'group',
+ 'type',
+ ])
})
})
@@ -201,14 +266,35 @@ describe('getHeadersForLayer - tracked entity', () => {
const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, {
layerHeaders,
})
- expect(dataKeys(result)).toEqual(['id', 'w75KJ2mc4zz', 'color'])
+ expect(dataKeys(result)).toEqual([
+ 'id',
+ 'orgUnitId',
+ 'orgUnitOwn',
+ 'level',
+ 'orgUnitPath',
+ 'createdAt',
+ 'updatedAt',
+ 'w75KJ2mc4zz',
+ 'color',
+ 'type',
+ ])
const nameHeader = result.headers.find(
(h) => h.dataKey === 'w75KJ2mc4zz'
)
expect(nameHeader.type).toBe(TYPE_STRING)
+ const createdHeader = result.headers.find(
+ (h) => h.dataKey === 'createdAt'
+ )
+ expect(createdHeader.type).toBe(TYPE_DATETIME)
+ expect(createdHeader.renderer).toBe(RENDERER_DATE)
+ const updatedHeader = result.headers.find(
+ (h) => h.dataKey === 'updatedAt'
+ )
+ expect(updatedHeader.type).toBe(TYPE_DATETIME)
+ expect(updatedHeader.renderer).toBe(RENDERER_DATE)
})
- test('custom DATE/DATETIME/TIME attributes get their matching type', () => {
+ test('custom DATE/DATETIME/TIME/BOOLEAN attributes get their matching type', () => {
const layerHeaders = [
{
name: 'Date of birth',
@@ -221,6 +307,16 @@ describe('getHeadersForLayer - tracked entity', () => {
valueType: 'DATETIME',
},
{ name: 'Visit time', dataKey: 'oZg33kd9taw', valueType: 'TIME' },
+ {
+ name: 'Referred by facility',
+ dataKey: 'c3d4e5f6a7b',
+ valueType: 'ORGANISATION_UNIT',
+ },
+ {
+ name: 'Follow-up',
+ dataKey: 'd4e5f6a7b8c',
+ valueType: 'BOOLEAN',
+ },
]
const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, {
layerHeaders,
@@ -231,9 +327,30 @@ describe('getHeadersForLayer - tracked entity', () => {
expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE)
expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME)
expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME)
+ expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING)
+ expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING)
expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE)
expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE)
expect(headerFor('oZg33kd9taw').renderer).toBe(RENDERER_DATE)
+ expect(headerFor('c3d4e5f6a7b').renderer).toBe(RENDERER_ORG_UNIT)
+ expect(headerFor('d4e5f6a7b8c').renderer).toBe(RENDERER_BOOLEAN)
+ })
+
+ test('option-set-backed custom attribute carries the optionSet id onto the header (was silently dropped before)', () => {
+ const layerHeaders = [
+ {
+ name: 'Gender',
+ dataKey: 'b2c3d4e5f6a',
+ valueType: 'TEXT',
+ optionSet: { id: 'os1' },
+ },
+ ]
+ const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, {
+ layerHeaders,
+ })
+ const header = result.headers.find((h) => h.dataKey === 'b2c3d4e5f6a')
+ expect(header.optionSet).toEqual({ id: 'os1' })
+ expect(header.type).toBe(TYPE_STRING)
})
})
@@ -247,7 +364,13 @@ describe('getHeadersForLayer - earth engine', () => {
},
})
expect(dataKeys(result)).toEqual(
- expect.arrayContaining(['name', 'id', 'type', '1'])
+ expect.arrayContaining([
+ 'id',
+ 'orgUnitOwn',
+ 'orgUnitPath',
+ 'type',
+ '1',
+ ])
)
const classHeader = result.headers.find((h) => h.dataKey === '1')
expect(classHeader.name).toBe('Forest')
diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js
index 76cf355527..746b4abd4f 100644
--- a/src/util/__tests__/tableSort.spec.js
+++ b/src/util/__tests__/tableSort.spec.js
@@ -1,6 +1,8 @@
import {
SENTINEL_NO_VALUE,
SENTINEL_SELECTED_ROW,
+ RENDERER_ORG_UNIT,
+ RENDERER_ORG_UNIT_NAME,
} from '../../constants/dataTable.js'
import {
compareBySelected,
@@ -76,6 +78,54 @@ describe('compareFieldValues', () => {
})
).toBeGreaterThan(0)
})
+
+ describe('org-unit-renderer columns - sorts by the resolved display name, not the raw stored path/id', () => {
+ const idToName = new Map([
+ ['country1', 'Sierra Leone'],
+ ['zFacility', 'Bargbe'],
+ ['aFacility', 'Upper Bambara'],
+ ])
+
+ it('RENDERER_ORG_UNIT_NAME: compares the resolved leaf name, not the raw id', () => {
+ expect(
+ compareFieldValues(
+ '/country1/aFacility',
+ '/country1/zFacility',
+ {
+ sortDirection: 'asc',
+ orgUnitRenderer: RENDERER_ORG_UNIT_NAME,
+ idToName,
+ }
+ )
+ ).toBeGreaterThan(0)
+ })
+
+ it('RENDERER_ORG_UNIT: compares the resolved full breadcrumb, not the raw path', () => {
+ expect(
+ compareFieldValues(
+ '/country1/aFacility',
+ '/country1/zFacility',
+ {
+ sortDirection: 'asc',
+ orgUnitRenderer: RENDERER_ORG_UNIT,
+ idToName,
+ }
+ )
+ ).toBeGreaterThan(0)
+ })
+
+ it('falls back to the raw value when no renderer is given (e.g. a plain string column)', () => {
+ expect(
+ compareFieldValues(
+ '/country1/aFacility',
+ '/country1/zFacility',
+ {
+ sortDirection: 'asc',
+ }
+ )
+ ).toBeLessThan(0)
+ })
+ })
})
describe('compareRangeValues', () => {
diff --git a/src/util/dateGroups.js b/src/util/dateGroups.js
new file mode 100644
index 0000000000..93b7fa0c81
--- /dev/null
+++ b/src/util/dateGroups.js
@@ -0,0 +1,158 @@
+import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js'
+import { formatDate, formatDatetime } from './helpers.js'
+import { dateLocale } from './time.js'
+
+export {
+ getNodeCheckState,
+ flattenVisibleNodes,
+ getSearchMatches,
+ nodeMatchesOrHasMatch,
+ togglePrefix as toggleDateGroupPrefix,
+} from './prefixTree.js'
+
+const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:([T ])(\d{2}))?/
+const TIME_KEY_PATTERN = /^(\d{2}):/
+
+export const parseDateGroupKey = (rawValue, granularity) => {
+ const str = String(rawValue)
+
+ if (granularity === TYPE_TIME) {
+ const match = TIME_KEY_PATTERN.exec(str)
+ return match ? { hour: match[1] } : null
+ }
+
+ const match = DATE_KEY_PATTERN.exec(str)
+ if (!match) {
+ return null
+ }
+ const [, year, month, day, delimiter, hour] = match
+ return {
+ year,
+ month: `${year}-${month}`,
+ day: `${year}-${month}-${day}`,
+ hour:
+ granularity === TYPE_DATETIME && delimiter && hour
+ ? `${year}-${month}-${day}${delimiter}${hour}`
+ : null,
+ }
+}
+
+const getOrCreateNode = (childMap, { key, level, label }) => {
+ let node = childMap.get(key)
+ if (!node) {
+ node = { key, level, label, prefix: key, childMap: new Map() }
+ childMap.set(key, node)
+ }
+ return node
+}
+
+const sortedNodes = (childMap) =>
+ Array.from(childMap.values()).map((node) => ({
+ key: node.key,
+ level: node.level,
+ label: node.label,
+ prefix: node.prefix,
+ children: sortedNodes(node.childMap),
+ }))
+
+const getValueFormatter = (granularity) =>
+ granularity === TYPE_DATETIME || granularity === TYPE_TIME
+ ? formatDatetime
+ : formatDate
+
+export const buildDateGroupTree = (values, granularity) => {
+ const rootMap = new Map()
+ const unparseable = []
+ const formatValue = getValueFormatter(granularity)
+
+ values.forEach((value) => {
+ const parsed = parseDateGroupKey(value, granularity)
+ if (!parsed) {
+ unparseable.push(value)
+ return
+ }
+
+ if (granularity === TYPE_TIME) {
+ const hourNode = getOrCreateNode(rootMap, {
+ key: parsed.hour,
+ level: 'hour',
+ })
+ getOrCreateNode(hourNode.childMap, {
+ key: value,
+ level: 'value',
+ label: formatValue(value),
+ })
+ return
+ }
+
+ const yearNode = getOrCreateNode(rootMap, {
+ key: parsed.year,
+ level: 'year',
+ })
+ const monthNode = getOrCreateNode(yearNode.childMap, {
+ key: parsed.month,
+ level: 'month',
+ })
+ const dayNode = getOrCreateNode(monthNode.childMap, {
+ key: parsed.day,
+ level: 'day',
+ })
+ const valueParentNode = parsed.hour
+ ? getOrCreateNode(dayNode.childMap, {
+ key: parsed.hour,
+ level: 'hour',
+ })
+ : dayNode
+ getOrCreateNode(valueParentNode.childMap, {
+ key: value,
+ level: 'value',
+ label: formatValue(value),
+ })
+ })
+
+ const tree = sortedNodes(rootMap)
+ const leaves = unparseable.map((value) => ({
+ key: value,
+ level: 'leaf',
+ label: value,
+ prefix: value,
+ children: [],
+ }))
+
+ return [...tree, ...leaves]
+}
+
+const getHourLabel = (key) => {
+ const match = key.match(/(\d{2})$/)
+ return match ? `${match[1]}:00` : key
+}
+
+export const formatNodeLabel = (node, locale) => {
+ const bcp47Locale = dateLocale(locale)
+ switch (node.level) {
+ case 'year':
+ return node.key
+ case 'month': {
+ const [, month] = node.key.split('-').map(Number)
+ return new Intl.DateTimeFormat(bcp47Locale, {
+ month: 'long',
+ }).format(new Date(2000, month - 1, 1))
+ }
+ case 'day': {
+ const [year, month, day] = node.key.split('-').map(Number)
+ const date = new Date(year, month - 1, day)
+ const weekday = new Intl.DateTimeFormat(bcp47Locale, {
+ weekday: 'long',
+ }).format(date)
+ const dayNumber = String(date.getDate()).padStart(2, '0')
+ return `${dayNumber} ${weekday}`
+ }
+ case 'hour':
+ return getHourLabel(node.key)
+ case 'value':
+ case 'leaf':
+ return node.label ?? node.key
+ default:
+ return node.key
+ }
+}
diff --git a/src/util/filter.js b/src/util/filter.js
index ca6fb5c7ef..9b8fc30f60 100644
--- a/src/util/filter.js
+++ b/src/util/filter.js
@@ -2,16 +2,18 @@ import {
SENTINEL_ANY_VALUE,
SENTINEL_NO_VALUE,
DATE_GROUPS_GRANULARITY,
+ ORG_UNIT_GROUPS_GRANULARITY,
} from '../constants/dataTable.js'
+import { formatOrgUnitPathBreadcrumb } from './orgUnitGroups.js'
-// Distinguishes a date-groups filter
-export const isDateGroupFilter = (filter) =>
+// Distinguishes a prefix-group filter (date-groups, org-unit-groups, ...)
+export const isPrefixGroupFilter = (filter, granularity) =>
filter != null &&
typeof filter === 'object' &&
!Array.isArray(filter) &&
- filter.granularity === DATE_GROUPS_GRANULARITY
+ filter.granularity === granularity
-export const dateGroupFilter = (value, { prefixes }) => {
+export const prefixGroupFilter = (value, { prefixes }) => {
if (!prefixes?.length) {
return true
}
@@ -27,6 +29,18 @@ export const dateGroupFilter = (value, { prefixes }) => {
})
}
+export const isDateGroupFilter = (filter) =>
+ isPrefixGroupFilter(filter, DATE_GROUPS_GRANULARITY)
+export const isOrgUnitGroupFilter = (filter) =>
+ isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY)
+
+export const isOrgUnitValueFilter = (filter) =>
+ filter != null &&
+ typeof filter === 'object' &&
+ !Array.isArray(filter) &&
+ Array.isArray(filter.values) &&
+ filter.searchDerived === true
+
// Filters an array of object with a set of filters
export const filterData = (data, filters) => {
if (!filters) {
@@ -44,14 +58,19 @@ export const filterData = (data, filters) => {
const props = d.properties || d // GeoJSON or plain object
const value = props[field]
- if (isDateGroupFilter(filter)) {
- return dateGroupFilter(value, filter)
+ if (isDateGroupFilter(filter) || isOrgUnitGroupFilter(filter)) {
+ return prefixGroupFilter(value, filter)
+ }
+
+ const stringValue =
+ value == null ? SENTINEL_NO_VALUE : String(value)
+
+ if (isOrgUnitValueFilter(filter)) {
+ return filter.values.includes(stringValue)
}
if (Array.isArray(filter)) {
// Multi-select: OR match against the raw stored value
- const stringValue =
- value == null ? SENTINEL_NO_VALUE : String(value)
return (
filter.length === 0 ||
filter.includes(stringValue) ||
@@ -85,18 +104,36 @@ export const numericFilter = (value, filter) => {
})
}
-// Case-insensitive match against any of the given string fields
-export const filterByGlobalSearch = (data, searchString, stringDataKeys) => {
- if (!searchString?.trim() || !stringDataKeys?.length) {
+export const filterByGlobalSearch = (
+ data,
+ searchString,
+ { stringDataKeys = [], orgUnitDataKeys = [], idToName } = {}
+) => {
+ if (
+ !searchString?.trim() ||
+ (!stringDataKeys.length && !orgUnitDataKeys.length)
+ ) {
return data
}
const lower = searchString.toLowerCase()
return data.filter((item) => {
const props = item.properties || item
- return stringDataKeys.some((key) => {
+ const stringMatch = stringDataKeys.some((key) => {
const val = props[key]
return val != null && String(val).toLowerCase().includes(lower)
})
+ if (stringMatch) {
+ return true
+ }
+ return orgUnitDataKeys.some((key) => {
+ const val = props[key]
+ return (
+ val != null &&
+ formatOrgUnitPathBreadcrumb(val, idToName)
+ .toLowerCase()
+ .includes(lower)
+ )
+ })
})
}
diff --git a/src/util/filterInput.js b/src/util/filterInput.js
index bb7773b636..9a3dc75d01 100644
--- a/src/util/filterInput.js
+++ b/src/util/filterInput.js
@@ -1,6 +1,6 @@
import i18n from '@dhis2/d2-i18n'
import { TYPE_NUMBER } from '../constants/dataTable.js'
-import { numericFilter } from './filter.js'
+import { isOrgUnitValueFilter, numericFilter } from './filter.js'
const POPOVER_ROW_NON_LABEL_WIDTH = 56
const MIN_POPOVER_WIDTH = 140
@@ -11,10 +11,15 @@ const MAX_POPOVER_WIDTH = 280
export const OPTION_ROW_HEIGHT = 28
export const MAX_LIST_HEIGHT = 260
-export const getSelectedAndAppliedString = (filterValue) => ({
- selected: Array.isArray(filterValue) ? filterValue : [],
- appliedString: typeof filterValue === 'string' ? filterValue : '',
-})
+export const getSelectedAndAppliedString = (filterValue) => {
+ if (isOrgUnitValueFilter(filterValue)) {
+ return { selected: [], appliedString: filterValue.searchText ?? '' }
+ }
+ return {
+ selected: Array.isArray(filterValue) ? filterValue : [],
+ appliedString: typeof filterValue === 'string' ? filterValue : '',
+ }
+}
export const getDisplayValue = ({
isOpen,
diff --git a/src/util/helpers.js b/src/util/helpers.js
index 3ada5f9837..f31544291b 100644
--- a/src/util/helpers.js
+++ b/src/util/helpers.js
@@ -156,7 +156,7 @@ export const formatCoordinate = (value) => {
}
// Formats a DHIS2 yes/no or yes only value
-const formatBoolean = (value) => {
+export const formatBoolean = (value) => {
if (value === 'true' || value === '1') {
return i18n.t('Yes')
}
diff --git a/src/util/map.js b/src/util/map.js
index 70526c6fa8..c9305d275f 100644
--- a/src/util/map.js
+++ b/src/util/map.js
@@ -1,4 +1,8 @@
import { compact, sortBy, isString } from 'lodash/fp'
+import {
+ ORG_UNIT_DATA_KEY,
+ ORG_UNIT_PATH_DATA_KEY,
+} from '../constants/dataTable.js'
import { dimConf } from '../constants/dimension.js'
export const toGeoJson = (organisationUnits) =>
@@ -16,21 +20,19 @@ export const toGeoJson = (organisationUnits) =>
}
}
- // Grand parent
- if (isString(ou.pg) && ou.pg.length) {
- const ids = compact(ou.pg.split('/'))
-
- // Grand parent id
- if (ids.length >= 2) {
- gpid = ids[ids.length - 2]
- }
+ const ancestorIds =
+ isString(ou.pg) && ou.pg.length ? compact(ou.pg.split('/')) : []
- // Grand parent parent graph
- if (ids.length > 2) {
- gppg = '/' + ids.slice(0, -2).join('/')
- }
+ // Grand parent
+ if (ancestorIds.length >= 2) {
+ gpid = ancestorIds[ancestorIds.length - 2]
+ }
+ if (ancestorIds.length > 2) {
+ gppg = '/' + ancestorIds.slice(0, -2).join('/')
}
+ const orgUnitPath = '/' + [...ancestorIds, ou.id].join('/')
+
return {
type: 'Feature',
id: ou.id,
@@ -50,6 +52,8 @@ export const toGeoJson = (organisationUnits) =>
parentGraph: ou.pg,
parentId: ou.pi,
parentName: ou.pn,
+ [ORG_UNIT_PATH_DATA_KEY]: orgUnitPath,
+ [ORG_UNIT_DATA_KEY]: orgUnitPath,
dimensions: ou.dimensions,
},
}
diff --git a/src/util/orgUnitGroups.js b/src/util/orgUnitGroups.js
new file mode 100644
index 0000000000..3e7e2657b6
--- /dev/null
+++ b/src/util/orgUnitGroups.js
@@ -0,0 +1,80 @@
+const getOrCreateNode = (childMap, { key, prefix, ouLevel }) => {
+ let node = childMap.get(key)
+ if (!node) {
+ node = { key, prefix, ouLevel, name: null, childMap: new Map() }
+ childMap.set(key, node)
+ }
+ return node
+}
+
+const sortedNodes = (childMap) =>
+ Array.from(childMap.values()).map((node) => ({
+ key: node.key,
+ prefix: node.prefix,
+ ouLevel: node.ouLevel,
+ name: node.name,
+ children: sortedNodes(node.childMap),
+ }))
+
+export const buildOrgUnitGroupTree = (pathValues) => {
+ const rootMap = new Map()
+
+ pathValues.forEach((path) => {
+ const ids = String(path).split('/').filter(Boolean)
+ let map = rootMap
+ let prefix = ''
+ ids.forEach((id, depth) => {
+ prefix += `/${id}`
+ const node = getOrCreateNode(map, {
+ key: id,
+ prefix,
+ ouLevel: depth + 1,
+ })
+ map = node.childMap
+ })
+ })
+
+ return sortedNodes(rootMap)
+}
+
+export const formatOrgUnitNodeLabel = (node, idToName) =>
+ idToName?.get(node.key) ?? node.key
+
+export const formatOrgUnitPathBreadcrumb = (path, idToName) =>
+ String(path)
+ .split('/')
+ .filter(Boolean)
+ .map((id) => idToName?.get(id) ?? id)
+ .join(' / ')
+
+export const formatOrgUnitOwnName = (path, idToName) => {
+ const leafId = String(path).split('/').findLast(Boolean)
+ return formatOrgUnitNodeLabel({ key: leafId }, idToName)
+}
+
+const collectOrgUnitMatches = (nodes, ancestors, options) => {
+ const { normalizedSearch, idToName, result } = options
+ nodes.forEach((node) => {
+ const name = idToName?.get(node.key)
+ const isMatch =
+ node.prefix.toLowerCase().includes(normalizedSearch) ||
+ name?.toLowerCase().includes(normalizedSearch)
+ if (isMatch) {
+ result.matchedKeys.add(node.key)
+ ancestors.forEach((key) => result.expandedAncestorKeys.add(key))
+ }
+ if (node.children.length) {
+ collectOrgUnitMatches(
+ node.children,
+ [...ancestors, node.key],
+ options
+ )
+ }
+ })
+}
+
+export const getOrgUnitSearchMatches = (tree, normalizedSearch, idToName) => {
+ const result = { matchedKeys: new Set(), expandedAncestorKeys: new Set() }
+ collectOrgUnitMatches(tree, [], { normalizedSearch, idToName, result })
+ return result
+}
diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js
index 746217ae19..421b3e261c 100644
--- a/src/util/orgUnits.js
+++ b/src/util/orgUnits.js
@@ -6,6 +6,12 @@ import {
import i18n from '@dhis2/d2-i18n'
import { uniqBy } from 'lodash/fp'
import { qualitativeColors } from '../constants/colors.js'
+import {
+ ORG_UNIT_LEVEL_DATA_KEY,
+ ORG_UNIT_DATA_KEY,
+ ORG_UNIT_ID_DATA_KEY,
+ ORG_UNIT_PATH_DATA_KEY,
+} from '../constants/dataTable.js'
import {
ORG_UNIT_COLOR,
ORG_UNIT_RADIUS,
@@ -21,7 +27,7 @@ import {
GEOFEATURES_QUERY,
ORG_UNITS_COUNT_QUERY,
ORG_UNITS_PATHS_QUERY,
- ORG_UNIT_DETAILS_QUERY,
+ ORG_UNIT_PATH_DETAILS_QUERY,
} from './requests.js'
// Expands the user's org unit tree into USER_ORGUNIT keyword id lists.
@@ -270,6 +276,9 @@ export const getCoordinateField = ({ orgUnitField, orgUnitFieldDisplayName }) =>
? { id: orgUnitField, name: orgUnitFieldDisplayName }
: null
+export const getMissingOrgUnitId = (feature) =>
+ feature.properties?.id ?? feature.id
+
export const getOrgUnitsWithoutCoordsCount = async ({
engine,
orgUnitIds,
@@ -330,25 +339,63 @@ const fetchInBatches = async (engine, ids, { query, buildVariables }) => {
.map((result) => result.value)
}
-export const fetchOrgUnitDetails = async (engine, ids) => {
+export const fetchOrgUnitPaths = async (engine, ids) => {
const results = await fetchInBatches(engine, ids, {
- query: ORG_UNIT_DETAILS_QUERY,
- buildVariables: (batch) => ({ ids: batch }),
+ query: ORG_UNITS_PATHS_QUERY,
+ buildVariables: (batch) => ({ ids: batch.join(',') }),
+ })
+ return results.flatMap((r) => r.organisationUnits.organisationUnits ?? [])
+}
+
+export const buildKnownOrgUnitNames = (rows = []) => {
+ const map = new Map()
+ rows.forEach((row) => {
+ if (row?.id != null && row?.name != null) {
+ map.set(row.id, row.name)
+ }
+ if (row?.parentId != null && row?.parentName != null) {
+ map.set(row.parentId, row.parentName)
+ }
+ })
+ return map
+}
+
+export const fetchOrgUnitPathDetails = async (engine, ids, nameProperty) => {
+ const results = await fetchInBatches(engine, ids, {
+ query: ORG_UNIT_PATH_DETAILS_QUERY,
+ buildVariables: (batch) => ({ ids: batch, nameProperty }),
})
return results.reduce((acc, result) => {
result.orgUnits.organisationUnits?.forEach((ou) => {
- acc[ou.id] = { level: ou.level, parentName: ou.parent?.name }
+ acc[ou.id] = { name: ou.name, level: ou.level }
})
return acc
}, {})
}
-export const fetchOrgUnitPaths = async (engine, ids) => {
- const results = await fetchInBatches(engine, ids, {
- query: ORG_UNITS_PATHS_QUERY,
- buildVariables: (batch) => ({ ids: batch.join(',') }),
+export const attachOrgUnitPaths = async (features, engine, getOuId) => {
+ if (!features?.length) {
+ return features
+ }
+ const distinctOuIds = [...new Set(features.map(getOuId).filter(Boolean))]
+ const ouPaths = await fetchOrgUnitPaths(engine, distinctOuIds)
+ const pathById = new Map(ouPaths.map((ou) => [ou.id, ou.path]))
+ return features.map((feature) => {
+ const ouId = getOuId(feature)
+ const path = pathById.get(ouId) ?? null
+ return {
+ ...feature,
+ properties: {
+ ...feature.properties,
+ [ORG_UNIT_ID_DATA_KEY]: ouId ?? null,
+ [ORG_UNIT_PATH_DATA_KEY]: path,
+ [ORG_UNIT_DATA_KEY]: path,
+ [ORG_UNIT_LEVEL_DATA_KEY]: path
+ ? path.split('/').filter(Boolean).length
+ : null,
+ },
+ }
})
- return results.flatMap((r) => r.organisationUnits.organisationUnits ?? [])
}
export const addGroupCountsToLegend = (legendItems, features, groupSet) => {
diff --git a/src/util/prefixTree.js b/src/util/prefixTree.js
new file mode 100644
index 0000000000..1cd84f1656
--- /dev/null
+++ b/src/util/prefixTree.js
@@ -0,0 +1,78 @@
+export const getNodeCheckState = (node, selectedPrefixes) => {
+ if (
+ selectedPrefixes.some(
+ (prefix) => node.prefix === prefix || node.prefix.startsWith(prefix)
+ )
+ ) {
+ return 'checked'
+ }
+ if (selectedPrefixes.some((prefix) => prefix.startsWith(node.prefix))) {
+ return 'indeterminate'
+ }
+ return 'unchecked'
+}
+
+export const togglePrefix = (selectedPrefixes, node) => {
+ const state = getNodeCheckState(node, selectedPrefixes)
+ if (state === 'checked') {
+ return selectedPrefixes.includes(node.prefix)
+ ? selectedPrefixes.filter((prefix) => prefix !== node.prefix)
+ : selectedPrefixes
+ }
+ return [
+ ...selectedPrefixes.filter((prefix) => !prefix.startsWith(node.prefix)),
+ node.prefix,
+ ]
+}
+
+export const flattenVisibleNodes = (tree, expandedKeys) => {
+ const result = []
+ const walk = (nodes, depth) => {
+ nodes.forEach((node) => {
+ result.push({ node, depth })
+ if (node.children.length && expandedKeys.has(node.key)) {
+ walk(node.children, depth + 1)
+ }
+ })
+ }
+ walk(tree, 0)
+ return result
+}
+
+const collectMatches = (nodes, ancestors, options) => {
+ const { normalizedSearch, result } = options
+ nodes.forEach((node) => {
+ const isMatch = node.prefix.toLowerCase().includes(normalizedSearch)
+ if (isMatch) {
+ result.matchedKeys.add(node.key)
+ ancestors.forEach((key) => result.expandedAncestorKeys.add(key))
+ }
+ if (node.children.length) {
+ collectMatches(node.children, [...ancestors, node.key], options)
+ }
+ })
+}
+
+export const getSearchMatches = (tree, normalizedSearch) => {
+ const result = { matchedKeys: new Set(), expandedAncestorKeys: new Set() }
+ collectMatches(tree, [], { normalizedSearch, result })
+ return result
+}
+
+export const nodeMatchesOrHasMatch = (node, matchedKeys) =>
+ matchedKeys.has(node.key) ||
+ node.children.some((child) => nodeMatchesOrHasMatch(child, matchedKeys))
+
+export const flattenAllNodes = (tree) => {
+ const result = []
+ const walk = (nodes) => {
+ nodes.forEach((node) => {
+ result.push(node)
+ if (node.children.length) {
+ walk(node.children)
+ }
+ })
+ }
+ walk(tree)
+ return result
+}
diff --git a/src/util/requests.js b/src/util/requests.js
index 2e827d914c..19d7518636 100644
--- a/src/util/requests.js
+++ b/src/util/requests.js
@@ -178,12 +178,12 @@ export const ORG_UNITS_COUNT_QUERY = {
},
}
-export const ORG_UNIT_DETAILS_QUERY = {
+export const ORG_UNIT_PATH_DETAILS_QUERY = {
orgUnits: {
resource: 'organisationUnits',
- params: ({ ids }) => ({
+ params: ({ ids, nameProperty }) => ({
filter: `id:in:[${ids.join(',')}]`,
- fields: 'id,level,parent[displayName~rename(name)]',
+ fields: `id,${nameProperty}~rename(name),level`,
paging: false,
}),
},
diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js
index 7e6be114cf..de376360b4 100644
--- a/src/util/tableHeaders.js
+++ b/src/util/tableHeaders.js
@@ -3,11 +3,19 @@ import {
RENDERER_COLOR,
RENDERER_ICON,
RENDERER_DATE,
+ RENDERER_ORG_UNIT,
+ RENDERER_ORG_UNIT_NAME,
+ RENDERER_BOOLEAN,
TYPE_NUMBER,
TYPE_STRING,
TYPE_DATE,
TYPE_DATETIME,
TYPE_TIME,
+ TYPE_ORG_UNIT,
+ ORG_UNIT_PATH_DATA_KEY,
+ ORG_UNIT_DATA_KEY,
+ ORG_UNIT_ID_DATA_KEY,
+ ORG_UNIT_LEVEL_DATA_KEY,
} from '../constants/dataTable.js'
import {
EVENT_LAYER,
@@ -23,6 +31,8 @@ import {
dateValueTypes,
datetimeValueTypes,
timeValueTypes,
+ ouValueTypes,
+ booleanValueTypes,
} from '../constants/valueTypes.js'
import { hasClasses } from './earthEngine.js'
import { getGeojsonDisplayData } from './geojson.js'
@@ -52,51 +62,96 @@ const getCustomFieldType = (valueType, hasOptionSet) => {
const DATE_LIKE_TYPES = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME])
-const getCustomFieldRenderer = (type) =>
- DATE_LIKE_TYPES.has(type) ? RENDERER_DATE : undefined
+const getCustomFieldRenderer = (type, valueType) => {
+ if (DATE_LIKE_TYPES.has(type)) {
+ return RENDERER_DATE
+ }
+ if (ouValueTypes.includes(valueType)) {
+ return RENDERER_ORG_UNIT
+ }
+ if (booleanValueTypes.includes(valueType)) {
+ return RENDERER_BOOLEAN
+ }
+ return undefined
+}
-const NAME = 'name'
const ID = 'id'
const VALUE = 'rawValue'
const LEGEND = 'legend'
const RANGE = 'range'
-const LEVEL = 'level'
-const PARENT_NAME = 'parentName'
+const LEVEL = ORG_UNIT_LEVEL_DATA_KEY
const TYPE = 'type'
const COLOR = 'color'
const GROUP = 'group'
const ICON = 'iconUrl'
-const OUNAME = 'ouname'
const OUBOUNDARY = 'ouBoundary'
const EVENTDATE = 'eventdate'
+const LASTUPDATED = 'lastupdated'
+const CREATEDAT = 'createdAt'
+const UPDATEDAT = 'updatedAt'
+const ORG_UNIT_PATH = ORG_UNIT_PATH_DATA_KEY
+const ORG_UNIT = ORG_UNIT_DATA_KEY
+const ORG_UNIT_ID = ORG_UNIT_ID_DATA_KEY
export const ERROR_NON_HOMOGENOUS_FEATURES = 'NON_HOMOGENOUS_FEATURES'
const defaultFieldsMap = () => ({
- [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING },
[ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING },
- [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER },
- [PARENT_NAME]: {
- name: i18n.t('Parent'),
- dataKey: PARENT_NAME,
+ [ORG_UNIT_ID]: {
+ name: i18n.t('Org unit Id'),
+ dataKey: ORG_UNIT_ID,
+ type: TYPE_STRING,
+ },
+ [ORG_UNIT]: {
+ name: i18n.t('Org unit'),
+ dataKey: ORG_UNIT,
type: TYPE_STRING,
+ renderer: RENDERER_ORG_UNIT_NAME,
},
- [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING },
+ [LEVEL]: {
+ name: i18n.t('Org unit level'),
+ dataKey: LEVEL,
+ type: TYPE_NUMBER,
+ },
+ [TYPE]: { name: i18n.t('Geometry type'), dataKey: TYPE, type: TYPE_STRING },
[VALUE]: { name: i18n.t('Value'), dataKey: VALUE, type: TYPE_NUMBER },
[LEGEND]: { name: i18n.t('Legend'), dataKey: LEGEND, type: TYPE_STRING },
[RANGE]: { name: i18n.t('Range'), dataKey: RANGE, type: TYPE_STRING },
- [OUNAME]: { name: i18n.t('Org unit'), dataKey: OUNAME, type: TYPE_STRING },
[OUBOUNDARY]: {
name: i18n.t('Org unit boundary'),
dataKey: OUBOUNDARY,
type: TYPE_STRING,
},
+ [ORG_UNIT_PATH]: {
+ name: i18n.t('Org unit hierarchy'),
+ dataKey: ORG_UNIT_PATH,
+ type: TYPE_ORG_UNIT,
+ renderer: RENDERER_ORG_UNIT,
+ },
[EVENTDATE]: {
name: i18n.t('Event date'),
dataKey: EVENTDATE,
type: TYPE_DATE,
renderer: RENDERER_DATE,
},
+ [LASTUPDATED]: {
+ name: i18n.t('Last updated'),
+ dataKey: LASTUPDATED,
+ type: TYPE_DATETIME,
+ renderer: RENDERER_DATE,
+ },
+ [CREATEDAT]: {
+ name: i18n.t('Created'),
+ dataKey: CREATEDAT,
+ type: TYPE_DATETIME,
+ renderer: RENDERER_DATE,
+ },
+ [UPDATEDAT]: {
+ name: i18n.t('Last updated'),
+ dataKey: UPDATEDAT,
+ type: TYPE_DATETIME,
+ renderer: RENDERER_DATE,
+ },
[COLOR]: {
name: i18n.t('Color'),
dataKey: COLOR,
@@ -112,6 +167,16 @@ const defaultFieldsMap = () => ({
},
})
+const idFieldAs = (name) => ({ ...defaultFieldsMap()[ID], name })
+
+const getOrgUnitCoreFields = (idLabel, { includeOrgUnitId = false } = {}) => [
+ idFieldAs(idLabel),
+ ...(includeOrgUnitId ? [defaultFieldsMap()[ORG_UNIT_ID]] : []),
+ defaultFieldsMap()[ORG_UNIT],
+ defaultFieldsMap()[LEVEL],
+ defaultFieldsMap()[ORG_UNIT_PATH],
+]
+
const getStyleHeaders = ({
hasLegend,
hasRange,
@@ -139,11 +204,12 @@ const getStyleHeaders = ({
}
const getThematicHeaders = () =>
- [NAME, ID, VALUE, LEVEL, PARENT_NAME, TYPE]
- .map((field) => defaultFieldsMap()[field])
+ getOrgUnitCoreFields(i18n.t('Org unit Id'))
+ .concat(defaultFieldsMap()[VALUE])
.concat(
getStyleHeaders({ hasLegend: true, hasRange: true, hasColor: true })
)
+ .concat(defaultFieldsMap()[TYPE])
const getMultiPeriodThematicHeaders = ({
isTimelineThematic,
@@ -184,29 +250,34 @@ const getEventHeaders = ({
styleDataItem,
countEventsOutsideOrgUnits,
}) => {
- const fields = [OUNAME, ID, EVENTDATE].map(
- (field) => defaultFieldsMap()[field]
- )
+ const fields = getOrgUnitCoreFields(i18n.t('Event Id'), {
+ includeOrgUnitId: true,
+ })
+ .concat(defaultFieldsMap()[EVENTDATE])
+ .concat(defaultFieldsMap()[LASTUPDATED])
if (countEventsOutsideOrgUnits) {
fields.push(defaultFieldsMap()[OUBOUNDARY])
}
+ // A handful of the analytics response's own fixed column names
+ // (e.g. "lastupdated", "eventstatus") happen to be 11 letters
+ const fixedDataKeys = new Set(fields.map((f) => f.dataKey))
+
const customFields = layerHeaders
- .filter(({ name }) => isValidUid(name))
+ .filter(({ name }) => isValidUid(name) && !fixedDataKeys.has(name))
.map(({ name: dataKey, column: name, valueType, optionSet }) => {
const type = getCustomFieldType(valueType, !!optionSet)
return {
name,
dataKey,
type,
- renderer: getCustomFieldRenderer(type),
+ renderer: getCustomFieldRenderer(type, valueType),
optionSet: optionSet || null,
}
})
customFields.push(
- defaultFieldsMap()[TYPE],
...getStyleHeaders({
hasLegend: !!styleDataItem,
hasRange: !!styleDataItem,
@@ -214,7 +285,7 @@ const getEventHeaders = ({
})
)
- return fields.concat(customFields)
+ return fields.concat(customFields).concat(defaultFieldsMap()[TYPE])
}
const getOrgUnitStyleHeaders = (data) => {
@@ -235,37 +306,39 @@ const getOrgUnitStyleHeaders = (data) => {
return getStyleHeaders({ hasGroup, hasColor, hasIcon })
}
-// Org unit and facility headers share the same shape
-const getFixedFieldsWithOrgUnitStyle = (fields, data) =>
- fields
- .map((field) => defaultFieldsMap()[field])
+const getFixedFieldsWithOrgUnitStyle = (data) =>
+ getOrgUnitCoreFields(i18n.t('Org unit Id'))
.concat(getOrgUnitStyleHeaders(data))
+ .concat(defaultFieldsMap()[TYPE])
-const getOrgUnitHeaders = (data) =>
- getFixedFieldsWithOrgUnitStyle([NAME, ID, LEVEL, PARENT_NAME, TYPE], data)
+const getOrgUnitHeaders = (data) => getFixedFieldsWithOrgUnitStyle(data)
const getTrackedEntityHeaders = ({ layerHeaders = [] }) => {
- const fields = [ID].map((field) => defaultFieldsMap()[field])
+ const fields = getOrgUnitCoreFields(i18n.t('Tracked entity Id'), {
+ includeOrgUnitId: true,
+ })
+ .concat(defaultFieldsMap()[CREATEDAT])
+ .concat(defaultFieldsMap()[UPDATEDAT])
const customFields = layerHeaders
.filter(({ dataKey }) => isValidUid(dataKey))
- .map(({ name, dataKey, valueType }) => {
- const type = getCustomFieldType(valueType, false)
+ .map(({ name, dataKey, valueType, optionSet }) => {
+ const type = getCustomFieldType(valueType, !!optionSet)
return {
name,
dataKey,
type,
- renderer: getCustomFieldRenderer(type),
+ renderer: getCustomFieldRenderer(type, valueType),
+ optionSet: optionSet || null,
}
})
customFields.push(...getStyleHeaders({ hasColor: true }))
- return fields.concat(customFields)
+ return fields.concat(customFields).concat(defaultFieldsMap()[TYPE])
}
-const getFacilityHeaders = (data) =>
- getFixedFieldsWithOrgUnitStyle([NAME, ID, TYPE], data)
+const getFacilityHeaders = (data) => getFixedFieldsWithOrgUnitStyle(data)
const toTitleCase = (str) =>
str.replace(
@@ -301,9 +374,9 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => {
})
}
- return [NAME, ID, TYPE]
- .map((field) => defaultFieldsMap()[field])
+ return getOrgUnitCoreFields(i18n.t('Org unit Id'))
.concat(customFields)
+ .concat(defaultFieldsMap()[TYPE])
}
const getGeoJsonUrlHeaders = (firstDataItem) =>
diff --git a/src/util/tableSort.js b/src/util/tableSort.js
index ac53e00f6b..8c11ea26e4 100644
--- a/src/util/tableSort.js
+++ b/src/util/tableSort.js
@@ -3,8 +3,14 @@ import {
SENTINEL_SELECTED_ROW,
SORT_ASCENDING,
TYPE_NUMBER,
+ RENDERER_ORG_UNIT,
+ RENDERER_ORG_UNIT_NAME,
} from '../constants/dataTable.js'
import { parseRange } from './legend.js'
+import {
+ formatOrgUnitOwnName,
+ formatOrgUnitPathBreadcrumb,
+} from './orgUnitGroups.js'
const RANGE = 'range'
@@ -59,10 +65,20 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => {
const isNoValue = (val) => val === undefined || val === null
+const resolveSortText = (value, renderer, idToName) => {
+ if (renderer === RENDERER_ORG_UNIT) {
+ return formatOrgUnitPathBreadcrumb(value, idToName)
+ }
+ if (renderer === RENDERER_ORG_UNIT_NAME) {
+ return formatOrgUnitOwnName(value, idToName)
+ }
+ return value
+}
+
export const compareFieldValues = (
aVal,
bVal,
- { sortField, sortDirection }
+ { sortField, sortDirection, orgUnitRenderer, idToName }
) => {
// All missing values should be sorted to the end
if (isNoValue(aVal) && isNoValue(bVal)) {
@@ -80,10 +96,12 @@ export const compareFieldValues = (
if (sortField === RANGE) {
return compareRangeValues(aVal, bVal, sortDirection)
}
+ const aText = resolveSortText(aVal, orgUnitRenderer, idToName)
+ const bText = resolveSortText(bVal, orgUnitRenderer, idToName)
// TODO: Make sure sorting works across different locales
return sortDirection === SORT_ASCENDING
- ? aVal.localeCompare(bVal)
- : bVal.localeCompare(aVal)
+ ? aText.localeCompare(bText)
+ : bText.localeCompare(aText)
}
export const compareRows = (a, b, options) => {