From 7e978a384e6ed1263c832600da985d902c12f471 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 17:36:21 +0200 Subject: [PATCH 01/17] feat: add bidirectional map/table selection sync and collapsible data table --- .../datatable/__tests__/useTableData.spec.jsx | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 0f9c59ea9..e5b26a98f 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2218,3 +2218,141 @@ describe('useTableData selectionFilter', () => { 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, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) + + test('excludes features without geometry when the toggle is on', () => { + const layerWithoutCoords = { + ...layer, + data: [layer.data[0]], + dataWithoutCoords: [ + { + id: 'nogeom', + properties: { id: 'nogeom', name: 'No geometry' }, + geometry: null, + }, + ], + } + + 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' + ) + }) +}) + +describe('useTableData showOnlySelected', () => { + 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 the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: false, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('includes only selected rows when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item A' + ) + }) + + test('shows no rows when the toggle is on and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(), + }) + expect(current.rows).toHaveLength(0) + }) +}) From bc9180184358031468f849430960b8f70338c4a0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 14 Jul 2026 22:18:57 +0200 Subject: [PATCH 02/17] feat: round out data table filtering with reverse-selection, zoom-to-filtered, and a richer selection filter --- .../datatable/__tests__/useTableData.spec.jsx | 39 +++++++++++++++---- src/components/datatable/useTableData.js | 2 +- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index e5b26a98f..9f941e961 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2300,7 +2300,7 @@ describe('useTableData showOnlyFeaturesInView', () => { }) }) -describe('useTableData showOnlySelected', () => { +describe('useTableData selectionFilter', () => { const store = { aggregations: {} } const layer = { @@ -2320,23 +2320,23 @@ describe('useTableData showOnlySelected', () => { ), }).result - test('includes all rows when the toggle is off', () => { + test('includes all rows when no filter is applied', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: false, + selectionFilter: [], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(2) }) - test('includes only selected rows when the toggle is on', () => { + test('includes only selected rows when filtered to "selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['selected'], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) @@ -2345,12 +2345,37 @@ describe('useTableData showOnlySelected', () => { ) }) - test('shows no rows when the toggle is on and nothing is selected', () => { + test('includes only non-selected rows when filtered to "not-selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item B' + ) + }) + + 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(), }) expect(current.rows).toHaveLength(0) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 131941cb7..986081e1c 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -275,7 +275,7 @@ export const useTableData = ({ } } - //sort + // Sort filteredData.sort((a, b) => compareRows(a, b, { sortField, sortDirection, selectedIdSet }) ) From 49e89670886ec177e00607b3985acb1053b591a4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 24 Jul 2026 12:32:17 +0200 Subject: [PATCH 03/17] feat: hierarchical drill-down filter for date/time data table columns --- .../datatable/DateGroupFilterInput.jsx | 500 ++++++++++++++++++ src/components/datatable/FilterInput.jsx | 18 +- .../__tests__/DateGroupFilterInput.spec.jsx | 378 +++++++++++++ src/util/__tests__/dateGroups.spec.js | 330 ++++++++++++ src/util/dateGroups.js | 217 ++++++++ 5 files changed, 1433 insertions(+), 10 deletions(-) create mode 100644 src/components/datatable/DateGroupFilterInput.jsx create mode 100644 src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx create mode 100644 src/util/__tests__/dateGroups.spec.js create mode 100644 src/util/dateGroups.js diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx new file mode 100644 index 000000000..02e91c43c --- /dev/null +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -0,0 +1,500 @@ +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, { useCallback, useMemo, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { Virtuoso } from 'react-virtuoso' +import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + DATE_GROUPS_GRANULARITY, +} from '../../constants/dataTable.js' +import { + buildDateGroupTree, + flattenVisibleNodes, + formatNodeLabel, + getNodeCheckState, + getSearchMatches, + nodeMatchesOrHasMatch, + toggleDateGroupPrefix, +} from '../../util/dateGroups.js' +import { isDateGroupFilter } from '../../util/filter.js' +import { + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, + getCyclicIndex, + getDisplayValue, + toOptionIndex, + toHighlightedIndex, +} from '../../util/filterInput.js' +import { toggleAnyValue } from '../../util/filterSelection.js' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' +import styles from './styles/FilterInput.module.css' + +const DATE_GROUP_POPOVER_WIDTH = 220 +const HELP_HEIGHT = 56 +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 INDENT_PX = 16 +const DATE_INPUT_DISALLOWED = /[^0-9\-:. T]/g + +const DateGroupFilterInput = ({ + dataKey, + name, + layerId, + filterValue, + options, + type, +}) => { + 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 = isDateGroupFilter(filterValue) + ? filterValue.prefixes + : [] + const appliedString = typeof filterValue === 'string' ? 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: DATE_GROUPS_GRANULARITY, + prefixes: nextPrefixes, + }) + ) + : dispatch(clearDataFilter(layerId, dataKey)), + [dispatch, layerId, dataKey] + ) + + 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( + () => buildDateGroupTree(realValues, type), + [realValues, type] + ) + + const normalizedSearch = searchText.trim().toLowerCase() + const searchMatches = useMemo( + () => + normalizedSearch ? getSearchMatches(tree, normalizedSearch) : null, + [tree, normalizedSearch] + ) + 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 = toggleDateGroupPrefix(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) => + text + ? dispatch(setDataFilter(layerId, dataKey, text)) + : dispatch(clearDataFilter(layerId, dataKey)) + + const onSearchChange = ({ value }) => { + const sanitized = value.replace(DATE_INPUT_DISALLOWED, '') + 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 ( +
+ + { + 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 = formatNodeLabel( + node, + i18n.language + ) + return ( +
+ {node.children.length > 0 ? ( + + ) : ( + + )} + + onToggleNode(node) + } + className={cx( + styles.denseCheckbox, + highlightedIndex === + toHighlightedIndex( + index, + showCustomFilterRow + ) && + styles.highlighted + )} + /> +
+ ) + }} + /> + )} +
+
+
+ )} +
+ ) +} + +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 0741860ad..c4ea35ae6 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -12,9 +12,9 @@ import { RENDERER_COLOR, RENDERER_ICON, TYPE_NUMBER, - // TYPE_DATE, - // TYPE_DATETIME, - // TYPE_TIME, + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' import { @@ -38,7 +38,7 @@ import { import { formatWithSeparator } from '../../util/numbers.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, @@ -561,10 +561,10 @@ 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 - /* return isDateType ? ( + return isDateType ? ( - ) : */ - - return optionSetId ? ( + ) : optionSetId ? ( { + 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/util/__tests__/dateGroups.spec.js b/src/util/__tests__/dateGroups.spec.js new file mode 100644 index 000000000..561b49ce5 --- /dev/null +++ b/src/util/__tests__/dateGroups.spec.js @@ -0,0 +1,330 @@ +import { + TYPE_DATE, + TYPE_DATETIME, + TYPE_TIME, +} from '../../constants/dataTable.js' +import { + parseDateGroupKey, + buildDateGroupTree, + getNodeCheckState, + toggleDateGroupPrefix, + flattenVisibleNodes, + formatNodeLabel, + getSearchMatches, + nodeMatchesOrHasMatch, +} 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('sorts nodes ascending at every level regardless of input order', () => { + const values = ['2024-01-01', '2023-05-15', '2023-01-01'] + const tree = buildDateGroupTree(values, TYPE_DATE) + expect(tree.map((y) => y.key)).toEqual(['2023', '2024']) + expect(tree[0].children.map((m) => m.key)).toEqual([ + '2023-01', + '2023-05', + ]) + }) + + 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('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('toggleDateGroupPrefix', () => { + it('selects an unchecked node', () => { + expect(toggleDateGroupPrefix([], { prefix: '2023' })).toEqual(['2023']) + }) + + it('deselects a node that is checked via its own prefix', () => { + expect( + toggleDateGroupPrefix(['2023-01', '2023'], { prefix: '2023' }) + ).toEqual(['2023-01']) + }) + + it('selecting a node drops now-redundant descendant prefixes', () => { + expect( + toggleDateGroupPrefix(['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( + toggleDateGroupPrefix(selected, { prefix: '2023-05-15 09' }) + ).toBe(selected) + }) + + it('selecting an indeterminate node adds it without touching unrelated selections', () => { + expect( + toggleDateGroupPrefix(['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('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' + ) + }) +}) + +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/dateGroups.js b/src/util/dateGroups.js new file mode 100644 index 000000000..b7fafc652 --- /dev/null +++ b/src/util/dateGroups.js @@ -0,0 +1,217 @@ +import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js' +import { formatDate, formatDatetime } from './helpers.js' +import { dateLocale } from './time.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 = str.match(TIME_KEY_PATTERN) + return match ? { hour: match[1] } : null + } + + const match = str.match(DATE_KEY_PATTERN) + 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()) + .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + .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] +} + +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 toggleDateGroupPrefix = (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 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 + } +} + +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)) From 39949f5299b02da068e10b4e1ce9d83bb7d77ba6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 24 Jul 2026 20:34:54 +0200 Subject: [PATCH 04/17] feat: hierarchical drill-down filter for org unit data table columns --- src/components/datatable/DataTable.jsx | 5 + src/components/datatable/FilterInput.jsx | 38 +- .../datatable/OrgUnitGroupFilterInput.jsx | 550 ++++++++++++++++++ src/components/datatable/RowCells.jsx | 24 +- .../OrgUnitGroupFilterInput.spec.jsx | 331 +++++++++++ .../datatable/__tests__/useTableData.spec.jsx | 416 +++++++++---- src/components/datatable/useTableData.js | 40 +- src/constants/dataTable.js | 14 + .../__tests__/useOrgUnitAncestorNames.spec.js | 64 ++ src/hooks/useOrgUnitAncestorNames.js | 54 ++ src/loaders/__tests__/eventLoader.spec.js | 54 ++ .../__tests__/trackedEntityLoader.spec.js | 16 + src/loaders/eventLoader.js | 17 + src/loaders/trackedEntityLoader.js | 13 +- src/util/__tests__/dateGroups.spec.js | 158 +---- src/util/__tests__/filter.spec.js | 86 ++- src/util/__tests__/map.spec.js | 57 +- src/util/__tests__/orgUnitGroups.spec.js | 177 ++++++ src/util/__tests__/orgUnits.spec.js | 82 +++ src/util/__tests__/prefixTree.spec.js | 145 +++++ src/util/__tests__/tableHeaders.spec.js | 76 ++- src/util/dateGroups.js | 97 +-- src/util/filter.js | 45 +- src/util/map.js | 28 +- src/util/orgUnitGroups.js | 96 +++ src/util/orgUnits.js | 45 ++ src/util/prefixTree.js | 78 +++ src/util/requests.js | 11 + src/util/tableHeaders.js | 120 ++-- 29 files changed, 2510 insertions(+), 427 deletions(-) create mode 100644 src/components/datatable/OrgUnitGroupFilterInput.jsx create mode 100644 src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx create mode 100644 src/hooks/__tests__/useOrgUnitAncestorNames.spec.js create mode 100644 src/hooks/useOrgUnitAncestorNames.js create mode 100644 src/util/__tests__/orgUnitGroups.spec.js create mode 100644 src/util/__tests__/prefixTree.spec.js create mode 100644 src/util/orgUnitGroups.js create mode 100644 src/util/prefixTree.js diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index c03403a3f..1e1061294 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/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index c4ea35ae6..d4cec63b3 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -11,10 +11,14 @@ import { SENTINEL_NO_VALUE, RENDERER_COLOR, RENDERER_ICON, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, TYPE_NUMBER, 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 { @@ -36,6 +40,10 @@ import { toggleRealValue, } from '../../util/filterSelection.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' @@ -44,6 +52,7 @@ import { 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 @@ -156,9 +165,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 +453,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 +493,7 @@ SearchableFilterPopover.propTypes = { } const PlainSearchableFilter = (props) => { - const { type } = props + const { type, renderer, orgUnitIdToName } = props const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -494,6 +503,12 @@ 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) + } return type === TYPE_NUMBER ? formatWithSeparator( Number(value), @@ -501,13 +516,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 +562,7 @@ const FilterInput = React.memo(function FilterInput({ options, optionSetId, renderer, + orgUnitIdToName, }) { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) @@ -573,6 +591,14 @@ const FilterInput = React.memo(function FilterInput({ options={options ?? []} type={type} /> + ) : type === TYPE_ORG_UNIT ? ( + ) : optionSetId ? ( ) }) @@ -603,6 +630,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/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx new file mode 100644 index 000000000..e6b397f51 --- /dev/null +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -0,0 +1,550 @@ +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, { useCallback, useMemo, useRef, useState } from 'react' +import { useDispatch } from 'react-redux' +import { Virtuoso } from 'react-virtuoso' +import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ORG_UNIT_GROUPS_GRANULARITY, +} from '../../constants/dataTable.js' +import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { isOrgUnitGroupFilter } from '../../util/filter.js' +import { + OPTION_ROW_HEIGHT, + MAX_LIST_HEIGHT, + getCyclicIndex, + getDisplayValue, + toOptionIndex, + toHighlightedIndex, +} from '../../util/filterInput.js' +import { toggleAnyValue } from '../../util/filterSelection.js' +import { + buildOrgUnitGroupTree, + formatOrgUnitNodeLabel, + getOrgUnitSearchMatches, +} from '../../util/orgUnitGroups.js' +import { + getNodeCheckState, + togglePrefix, + flattenAllNodes, + flattenVisibleNodes, + nodeMatchesOrHasMatch, +} from '../../util/prefixTree.js' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' +import styles from './styles/FilterInput.module.css' + +const ORG_UNIT_GROUP_POPOVER_WIDTH = 220 +const HELP_HEIGHT = 56 +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 INDENT_PX = 16 + +const OrgUnitGroupFilterInput = ({ + dataKey, + name, + layerId, + filterValue, + options, +}) => { + 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) + + // A committed free-text search is kept out of `selectedPrefixes` on + // purpose - like every other column's typed "Contains" filter, it + // narrows the table live but does not show any checkbox as checked + // (see applyCustomFilter below). + const selectedPrefixes = + isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived + ? filterValue.prefixes + : [] + const appliedString = isOrgUnitGroupFilter(filterValue) + ? filterValue.searchDerived + ? filterValue.searchText + : '' + : typeof filterValue === 'string' + ? 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: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: nextPrefixes, + }) + ) + : dispatch(clearDataFilter(layerId, dataKey)), + [dispatch, layerId, dataKey] + ) + + 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(() => buildOrgUnitGroupTree(realValues), [realValues]) + + const { idToName } = useOrgUnitAncestorNames(realValues) + + const nodeByKey = useMemo(() => { + const map = new Map() + flattenAllNodes(tree).forEach((node) => map.set(node.key, node)) + return map + }, [tree]) + + const normalizedSearch = searchText.trim().toLowerCase() + const searchMatches = useMemo( + () => + normalizedSearch + ? getOrgUnitSearchMatches(tree, normalizedSearch, idToName) + : null, + [tree, normalizedSearch, idToName] + ) + 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] + ) + + // Unlike dates, an org unit's raw stored value is an id (or id path), + // never the human-readable name a user actually types here - matching + // "Contains" against that raw value would silently match nothing for + // any real-world search term. Committing free text instead narrows the + // table to every currently name/id-matched org unit - same live-as-you- + // type "Contains" semantics every other column's filter already has, + // dispatched with `searchDerived` so it (like every other column's + // typed filter) never shows as a checked box while typing. + const applyCustomFilter = (text) => { + const trimmed = text.trim() + if (!trimmed) { + dispatch(clearDataFilter(layerId, dataKey)) + return + } + const matches = getOrgUnitSearchMatches( + tree, + trimmed.toLowerCase(), + idToName + ) + const matchedPrefixes = [...matches.matchedKeys] + .map((key) => nodeByKey.get(key)) + .filter(Boolean) + .map((node) => node.prefix) + if (!matchedPrefixes.length) { + dispatch(clearDataFilter(layerId, dataKey)) + return + } + dispatch( + setDataFilter(layerId, dataKey, { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: matchedPrefixes, + searchDerived: true, + searchText: trimmed, + }) + ) + } + + const onSearchChange = ({ value }) => { + setSearchText(value) + setHighlightedIndex(-1) + + const trimmed = value.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 ( +
+ + { + 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 = formatOrgUnitNodeLabel( + node, + idToName + ) + return ( +
+ {node.children.length > 0 ? ( + + ) : ( + + )} + + onToggleNode(node) + } + className={cx( + styles.denseCheckbox, + highlightedIndex === + toHighlightedIndex( + index, + showCustomFilterRow + ) && + styles.highlighted + )} + /> +
+ ) + }} + /> + )} +
+
+
+ )} +
+ ) +} + +OrgUnitGroupFilterInput.propTypes = { + dataKey: PropTypes.string.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 541298e88..cb5d434ca 100644 --- a/src/components/datatable/RowCells.jsx +++ b/src/components/datatable/RowCells.jsx @@ -6,12 +6,19 @@ import { RENDERER_COLOR, RENDERER_ICON, RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, 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 { 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 +35,7 @@ const RowCells = ({ rendererByDataKey, typeByDataKey, keyAnalysisDigitGroupSeparator, + orgUnitIdToName, onToggleSelection, }) => { const rowId = getRowId(row) @@ -78,6 +86,8 @@ 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 return ( ({ + __esModule: true, + default: jest.fn(), +})) + +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()) + +beforeEach(() => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map(), + loading: false, + }) +}) + +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', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + renderOrgUnitGroupFilter() + 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', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + renderOrgUnitGroupFilter() + 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', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + const { store } = renderOrgUnitGroupFilter() + 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', () => { + useOrgUnitAncestorNames.mockReturnValue({ + idToName: new Map([['country1', 'Sierra Leone']]), + loading: false, + }) + renderOrgUnitGroupFilter({ + 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 9f941e961..b085b4bb7 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', @@ -564,13 +601,16 @@ describe('useTableData headers', () => { }, { 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,34 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(4) + expect(headers).toHaveLength(9) 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: '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(9) 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: 'Gabrielle', dataKey: 'w75KJ2mc4zz' }, { value: 28, dataKey: 'zDhUuAYrxNC' }, { value: '#e57200', dataKey: 'color' }, + { value: undefined, dataKey: 'type' }, ]) expect(isLoading).toBe(false) }) @@ -1034,11 +1088,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 +1110,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 +1251,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 +1273,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 +1367,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 +1391,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 +1403,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 +1417,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithStringData, - sortField: 'name', + sortField: 'orgUnitOwn', sortDirection: 'asc', }), { @@ -1353,7 +1427,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 +1439,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 +1453,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithStringData, - sortField: 'name', + sortField: 'orgUnitOwn', sortDirection: 'desc', }), { @@ -1387,7 +1463,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 +1505,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 +1549,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 +1561,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 +1588,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 +1693,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 +1719,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 +1765,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 +1777,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 +1829,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 +1841,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 +1860,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 +1868,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 +1959,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 +1985,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 +2034,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 +2058,7 @@ describe('useTableData columnOptions', () => { () => useTableData({ layer, - sortField: 'name', + sortField: 'orgUnitOwn', sortDirection: 'desc', }), { @@ -1992,8 +2068,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 +2092,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 +2114,7 @@ describe('useTableData globalSearch', () => { () => useTableData({ layer, - sortField: 'name', + sortField: 'id', sortDirection: 'asc', globalSearch, }), @@ -2042,11 +2130,111 @@ describe('useTableData globalSearch', () => { expect(current.rows).toHaveLength(2) }) - test('matches case-insensitively across any string column', () => { - const { current } = renderTableData('uganda') + 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( - 'Kampala' + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( + 'facility-a' + ) + }) + + 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: [ + { + name: 'c3d4e5f6a7b', + column: 'Referred by facility', + valueType: 'ORGANISATION_UNIT', + }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + eventdate: '2023-01-01', + c3d4e5f6a7b: 'Referral Hospital', + }, + }, + ], + } + 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') + }) + + 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, + }) + 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 + + expect(renderTeiTableData('referral').current.rows).toHaveLength(0) + + const { current } = renderTeiTableData('facility9') + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe( + 'tei1' ) }) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 986081e1c..04e37c891 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,6 +19,7 @@ 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 { buildRowCells, @@ -237,6 +241,27 @@ export const useTableData = ({ return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS }, [columnDistinctValues, sortField, sortDirection]) + // Every column whose cell needs an id/path resolved to a readable name - + // "Org unit hierarchy" (tree-filterable) plus "Org unit" and any custom + // ORGANISATION_UNIT-valued field (plain-text filterable, but their + // cells still resolve for display) - keyed by renderer rather than + // type, since only the hierarchy column is still TYPE_ORG_UNIT. + 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 { idToName: orgUnitIdToName } = + useOrgUnitAncestorNames(orgUnitPathValues) + const rows = useMemo(() => { if (errorCode.current) { return null @@ -253,11 +278,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) { @@ -292,6 +320,7 @@ export const useTableData = ({ sortDirection, selectionFilter, selectedIdSetDependency, + orgUnitIdToName, ]) // EE layers and event layers may be loading additional data @@ -322,6 +351,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 0bac15662..f08391810 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -8,11 +8,25 @@ 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 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' + +// Full ancestor path (breadcrumb renderer) - "Org unit hierarchy" column +export const ORG_UNIT_PATH_DATA_KEY = 'orgUnitPath' +// Same path value as ORG_UNIT_PATH_DATA_KEY, rendered as the leaf name only - "Org unit" column +export const ORG_UNIT_DATA_KEY = 'orgUnitOwn' +// The layer's own org unit's bare id - "Org unit Id" column (Event/Tracked entity layers only, +// whose own "Id" field is the event/tracked-entity id, not the org unit id) +export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' +// The org unit's own hierarchy depth (1 = country, 2 = region, ...) - "Org unit level" column +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 000000000..654ef013f --- /dev/null +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -0,0 +1,64 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js' +import useOrgUnitAncestorNames from '../useOrgUnitAncestorNames.js' + +// A stable reference, matching the real useDataEngine's contract - an +// unstable mock (a fresh object per call) would retrigger the hook's effect +// on every state update it causes, since `engine` is one of its deps +jest.mock('@dhis2/app-runtime', () => ({ + useDataEngine: () => mockEngine, +})) +const mockEngine = {} + +jest.mock('../../util/orgUnits.js', () => ({ + fetchOrgUnitPathDetails: jest.fn(), +})) + +beforeEach(() => { + fetchOrgUnitPathDetails.mockReset() +}) + +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', + ]) + ) + }) + + 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') + }) +}) diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js new file mode 100644 index 000000000..ab66624c7 --- /dev/null +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -0,0 +1,54 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import { useEffect, useMemo, useState } from 'react' +import { fetchOrgUnitPathDetails } from '../util/orgUnits.js' + +// Resolves the distinct ancestor ids across a set of org-unit path values +// (e.g. '/ImspTQPwCqd/O6uvpzGd5pu') to real display names, batched in one +// bulk request. Ids are not human-readable on their own - unlike the date +// tree, an org unit's raw value doesn't self-describe its label. Callers +// (the table cell renderer and OrgUnitGroupFilterInput.jsx) render the raw +// id as a placeholder until `idToName` resolves, rather than blocking. +const useOrgUnitAncestorNames = (distinctPathValues) => { + const engine = useDataEngine() + 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 + } + let cancelled = false + setLoading(true) + fetchOrgUnitPathDetails(engine, ids).then((details) => { + if (cancelled) { + return + } + setIdToName( + new Map(Object.entries(details).map(([id, d]) => [id, d.name])) + ) + 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]) + + return { idToName, loading } +} + +export default useOrgUnitAncestorNames diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 7a58552fe..8ddca662b 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,59 @@ 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() + }) +}) + describe('shouldUseServerCluster', () => { const overThreshold = EVENT_SERVER_CLUSTER_COUNT + 1 diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index a9a69ccee..29f955c4f 100644 --- a/src/loaders/__tests__/trackedEntityLoader.spec.js +++ b/src/loaders/__tests__/trackedEntityLoader.spec.js @@ -153,9 +153,25 @@ 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') + }) }) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 275ded1db..6df82b4db 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,20 @@ import { isValidUid } from '../util/uid.js' const getEventOuId = (feature) => feature.properties?.ou ?? feature.properties?.['Organisation unit'] +// Attaches each event's org unit ancestor path (data table "Org unit +// hierarchy" column) - see util/orgUnits.js's attachOrgUnitPaths, shared +// with trackedEntityLoader.js. +export const attachOrgUnitPaths = async ({ config, engine }) => { + if (!config.data?.length) { + return + } + config.data = await attachOrgUnitPathsUtil( + config.data, + engine, + getEventOuId + ) +} + // Expands USER_ORGUNIT/_CHILDREN/_GRANDCHILDREN into ids; [id] if literal. const expandOrgUnitKeyword = (id, userOrgUnitIdsByKeyword) => { if (id in userOrgUnitIdsByKeyword) { @@ -334,6 +349,8 @@ const loadEventLayer = async ({ }) } + await attachOrgUnitPaths({ config, engine }) + if (styleDataItem) { await styleByDataItem(config, engine) } diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index b50e5b8e5..7bdb84e54 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -19,10 +19,11 @@ import { GEO_TYPE_FEATURE, } from '../util/geojson.js' import { parseWithSeparator } from '../util/numbers.js' +import { attachOrgUnitPaths } from '../util/orgUnits.js' import { getDataWithRelationships } from '../util/teiRelationshipsParser.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' -const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes'] +const fields = ['trackedEntity~rename(id)', 'geometry', 'attributes', 'orgUnit'] // Valid geometry types for TEIs const teiGeometryTypes = new Set([ @@ -132,12 +133,14 @@ 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 }) => ({ + instances.map(({ id, geometry, attributes, orgUnit }) => ({ type: GEO_TYPE_FEATURE, geometry, properties: { id, color, + orgUnit, + type: geometry?.type, ...getAttributeProperties(attributes), }, })) @@ -383,6 +386,12 @@ const trackedEntityLoader = async ({ data = toGeoJson(instances, pointColor) } + 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 index 561b49ce5..7f09529e4 100644 --- a/src/util/__tests__/dateGroups.spec.js +++ b/src/util/__tests__/dateGroups.spec.js @@ -6,12 +6,7 @@ import { import { parseDateGroupKey, buildDateGroupTree, - getNodeCheckState, - toggleDateGroupPrefix, - flattenVisibleNodes, formatNodeLabel, - getSearchMatches, - nodeMatchesOrHasMatch, } from '../dateGroups.js' describe('parseDateGroupKey', () => { @@ -121,14 +116,22 @@ describe('buildDateGroupTree', () => { expect(tree[1].children.map((v) => v.label)).toEqual(['14:00:00']) }) - it('sorts nodes ascending at every level regardless of input order', () => { - const values = ['2024-01-01', '2023-05-15', '2023-01-01'] - const tree = buildDateGroupTree(values, TYPE_DATE) - expect(tree.map((y) => y.key)).toEqual(['2023', '2024']) - expect(tree[0].children.map((m) => m.key)).toEqual([ + 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', () => { @@ -144,100 +147,6 @@ describe('buildDateGroupTree', () => { }) }) -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('toggleDateGroupPrefix', () => { - it('selects an unchecked node', () => { - expect(toggleDateGroupPrefix([], { prefix: '2023' })).toEqual(['2023']) - }) - - it('deselects a node that is checked via its own prefix', () => { - expect( - toggleDateGroupPrefix(['2023-01', '2023'], { prefix: '2023' }) - ).toEqual(['2023-01']) - }) - - it('selecting a node drops now-redundant descendant prefixes', () => { - expect( - toggleDateGroupPrefix(['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( - toggleDateGroupPrefix(selected, { prefix: '2023-05-15 09' }) - ).toBe(selected) - }) - - it('selecting an indeterminate node adds it without touching unrelated selections', () => { - expect( - toggleDateGroupPrefix(['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('formatNodeLabel', () => { it('formats a year node verbatim', () => { expect(formatNodeLabel({ level: 'year', key: '2023' }, 'en')).toBe( @@ -287,44 +196,3 @@ describe('formatNodeLabel', () => { ) }) }) - -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__/filter.spec.js b/src/util/__tests__/filter.spec.js index 5dc706983..e07e6dec2 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,50 @@ 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('filterByGlobalSearch', () => { @@ -211,27 +256,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 +285,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 286e0f264..39fd3bded 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 000000000..e803df769 --- /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 55362e6e8..0427f2729 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -13,6 +13,8 @@ import { getUserOrgUnitIdsByKeyword, fetchOrgUnitDetails, fetchOrgUnitPaths, + fetchOrgUnitPathDetails, + attachOrgUnitPaths, } from '../orgUnits.js' describe('getUserOrgUnitIdsByKeyword', () => { @@ -109,6 +111,86 @@ 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 }, + }) + }) +}) + +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('getStyledOrgUnits', () => { diff --git a/src/util/__tests__/prefixTree.spec.js b/src/util/__tests__/prefixTree.spec.js new file mode 100644 index 000000000..0282281c1 --- /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 b130fe4d2..8a322f1c9 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -1,4 +1,4 @@ -import { RENDERER_DATE } from '../../constants/dataTable.js' +import { RENDERER_DATE, RENDERER_ORG_UNIT } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -30,15 +30,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 +54,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 +96,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 +132,11 @@ describe('getHeadersForLayer - event', () => { valueType: 'TEXT', optionSet: { id: 'os1' }, }, + { + name: 'c3d4e5f6a7b', + column: 'Referred by facility', + valueType: 'ORGANISATION_UNIT', + }, ] const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) const headerFor = (dataKey) => @@ -135,11 +147,18 @@ describe('getHeadersForLayer - event', () => { expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE) expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING) + // Unlike a tracked entity attribute, the events analytics query + // always resolves an ORGANISATION_UNIT-valued data element to its + // display name server-side - there's no id left to build a tree + // filter from, so it stays plain text. The cell renderer still + // applies (a harmless no-op here, since the value is already a name). + expect(typeOf('c3d4e5f6a7b')).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) }) test('adds the org unit boundary column only when countEventsOutsideOrgUnits is set', () => { @@ -172,11 +191,11 @@ describe('getHeadersForLayer - org unit / facility', () => { }) expect(dataKeys(result)).toEqual( expect.arrayContaining([ - 'name', 'id', + 'orgUnitOwn', 'level', - 'parentName', 'type', + 'orgUnitPath', 'color', 'iconUrl', ]) @@ -188,7 +207,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,7 +227,16 @@ 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', + 'w75KJ2mc4zz', + 'color', + 'type', + ]) const nameHeader = result.headers.find( (h) => h.dataKey === 'w75KJ2mc4zz' ) @@ -221,6 +256,11 @@ describe('getHeadersForLayer - tracked entity', () => { valueType: 'DATETIME', }, { name: 'Visit time', dataKey: 'oZg33kd9taw', valueType: 'TIME' }, + { + name: 'Referred by facility', + dataKey: 'c3d4e5f6a7b', + valueType: 'ORGANISATION_UNIT', + }, ] const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { layerHeaders, @@ -231,9 +271,13 @@ describe('getHeadersForLayer - tracked entity', () => { expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) + // Plain text now (no tree filter), but the cell renderer still + // resolves the tracker API's raw bare id to a readable name. + expect(typeOf('c3d4e5f6a7b')).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) }) }) @@ -247,7 +291,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/dateGroups.js b/src/util/dateGroups.js index b7fafc652..5eca07fd9 100644 --- a/src/util/dateGroups.js +++ b/src/util/dateGroups.js @@ -1,7 +1,16 @@ import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js' import { formatDate, formatDatetime } from './helpers.js' +import { togglePrefix } from './prefixTree.js' import { dateLocale } from './time.js' +export { + getNodeCheckState, + flattenVisibleNodes, + getSearchMatches, + nodeMatchesOrHasMatch, +} from './prefixTree.js' +export const toggleDateGroupPrefix = togglePrefix + const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:([T ])(\d{2}))?/ const TIME_KEY_PATTERN = /^(\d{2}):/ @@ -38,16 +47,21 @@ const getOrCreateNode = (childMap, { key, level, label }) => { return node } +// Preserves encounter order rather than re-sorting: buildDateGroupTree's +// caller (DateGroupFilterInput.jsx) always receives values already ordered +// to match the column's current sort direction (see useTableData.js's +// columnOptions) - walking them in that order naturally reproduces the same +// ascending/descending order at every level of the tree, so the popover's +// checkbox order stays consistent with the column header's sort, just like +// every other filter popover's option list already does. const sortedNodes = (childMap) => - Array.from(childMap.values()) - .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) - .map((node) => ({ - key: node.key, - level: node.level, - label: node.label, - prefix: node.prefix, - children: sortedNodes(node.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 @@ -116,47 +130,6 @@ export const buildDateGroupTree = (values, granularity) => { return [...tree, ...leaves] } -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 toggleDateGroupPrefix = (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 getHourLabel = (key) => { const match = key.match(/(\d{2})$/) return match ? `${match[1]}:00` : key @@ -191,27 +164,3 @@ export const formatNodeLabel = (node, locale) => { return node.key } } - -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)) diff --git a/src/util/filter.js b/src/util/filter.js index ca6fb5c7e..01e0ff2bb 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,11 @@ export const dateGroupFilter = (value, { prefixes }) => { }) } +export const isDateGroupFilter = (filter) => + isPrefixGroupFilter(filter, DATE_GROUPS_GRANULARITY) +export const isOrgUnitGroupFilter = (filter) => + isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY) + // Filters an array of object with a set of filters export const filterData = (data, filters) => { if (!filters) { @@ -44,8 +51,8 @@ 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) } if (Array.isArray(filter)) { @@ -85,18 +92,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/map.js b/src/util/map.js index 70526c6fa..c9305d275 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 000000000..51defde71 --- /dev/null +++ b/src/util/orgUnitGroups.js @@ -0,0 +1,96 @@ +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 +} + +// Preserves encounter order rather than re-sorting: buildOrgUnitGroupTree's +// caller (OrgUnitGroupFilterInput.jsx) always receives pathValues already +// ordered to match the column's current sort direction (see useTableData.js's +// columnOptions) - walking them in that order naturally reproduces the same +// ascending/descending order at every level of the tree, so the popover's +// checkbox order stays consistent with the column header's sort, just like +// every other filter popover's option list already does. +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), + })) + +// Builds an ancestor-path tree (Country -> Region -> District -> Facility, +// or however many levels a given path has) from a column's flat distinct +// full-path values (e.g. '/ImspTQPwCqd/O6uvpzGd5pu/lc3eMKXaEfw'). Unlike +// dateGroups.js's tree, an org unit's own id is naturally the tree's leaf - +// no separate terminal "value" node is needed, since the path's last +// segment already is the selectable unit. `name` starts null on every node; +// callers resolve it asynchronously and re-render (see +// src/hooks/useOrgUnitAncestorNames.js), falling back to the raw id label +// until then. +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('/').filter(Boolean).pop() + 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 && 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 746217ae1..e512a8bad 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, @@ -22,6 +28,7 @@ import { 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. @@ -351,6 +358,44 @@ export const fetchOrgUnitPaths = async (engine, ids) => { return results.flatMap((r) => r.organisationUnits.organisationUnits ?? []) } +export const fetchOrgUnitPathDetails = async (engine, ids) => { + const results = await fetchInBatches(engine, ids, { + query: ORG_UNIT_PATH_DETAILS_QUERY, + buildVariables: (batch) => ({ ids: batch }), + }) + return results.reduce((acc, result) => { + result.orgUnits.organisationUnits?.forEach((ou) => { + acc[ou.id] = { name: ou.name, level: ou.level } + }) + return acc + }, {}) +} + +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, + }, + } + }) +} + export const addGroupCountsToLegend = (legendItems, features, groupSet) => { legendItems.forEach((item) => (item.count = 0)) const unclassifiedItem = legendItems.find((i) => !i.id) diff --git a/src/util/prefixTree.js b/src/util/prefixTree.js new file mode 100644 index 000000000..1cd84f165 --- /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 2e827d914..0006ed0bf 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -188,3 +188,14 @@ export const ORG_UNIT_DETAILS_QUERY = { }), }, } + +export const ORG_UNIT_PATH_DETAILS_QUERY = { + orgUnits: { + resource: 'organisationUnits', + params: ({ ids }) => ({ + filter: `id:in:[${ids.join(',')}]`, + fields: 'id,displayName~rename(name),level', + paging: false, + }), + }, +} diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index 7e6be114c..c12df0911 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -3,11 +3,18 @@ import { RENDERER_COLOR, RENDERER_ICON, RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_ORG_UNIT_NAME, 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 +30,7 @@ import { dateValueTypes, datetimeValueTypes, timeValueTypes, + ouValueTypes, } from '../constants/valueTypes.js' import { hasClasses } from './earthEngine.js' import { getGeojsonDisplayData } from './geojson.js' @@ -31,6 +39,14 @@ import { isValidUid } from './uid.js' export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME } +// A custom ORGANISATION_UNIT-valued field is always plain text, on both +// Event and Tracked Entity layers: the events analytics query always +// resolves it to a display name server-side (a hardcoded `_name` column +// select - no outputIdScheme param can change this), and tracker attribute +// values are a bare id with no ancestor chain to reverse-resolve safely +// (org unit names aren't guaranteed unique). Either way there's no reliable +// path/ancestor data to build a tree filter from - only "Org unit +// hierarchy" (the layer's own org unit) gets that treatment. const getCustomFieldType = (valueType, hasOptionSet) => { if (hasOptionSet) { return TYPE_STRING @@ -52,45 +68,72 @@ 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 +// Keyed off valueType (not the column's TYPE_STRING type) so an +// ORGANISATION_UNIT-valued field's cell still resolves to a readable name: +// a real id->name lookup for tracker-sourced (Tracked Entity) values, and a +// harmless no-op for analytics-sourced (Event) values that are already a +// name (formatOrgUnitPathBreadcrumb falls back to the raw string when it +// finds no matching id in idToName). +const getCustomFieldRenderer = (type, valueType) => { + if (DATE_LIKE_TYPES.has(type)) { + return RENDERER_DATE + } + if (ouValueTypes.includes(valueType)) { + return RENDERER_ORG_UNIT + } + 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 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, }, - [TYPE]: { name: i18n.t('Type'), dataKey: TYPE, type: TYPE_STRING }, + [ORG_UNIT]: { + name: i18n.t('Org unit'), + dataKey: ORG_UNIT, + type: TYPE_STRING, + renderer: RENDERER_ORG_UNIT_NAME, + }, + [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, @@ -112,6 +155,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 +192,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,9 +238,9 @@ 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]) if (countEventsOutsideOrgUnits) { fields.push(defaultFieldsMap()[OUBOUNDARY]) @@ -200,13 +254,12 @@ const getEventHeaders = ({ name, dataKey, type, - renderer: getCustomFieldRenderer(type), + renderer: getCustomFieldRenderer(type, valueType), optionSet: optionSet || null, } }) customFields.push( - defaultFieldsMap()[TYPE], ...getStyleHeaders({ hasLegend: !!styleDataItem, hasRange: !!styleDataItem, @@ -214,7 +267,7 @@ const getEventHeaders = ({ }) ) - return fields.concat(customFields) + return fields.concat(customFields).concat(defaultFieldsMap()[TYPE]) } const getOrgUnitStyleHeaders = (data) => { @@ -235,17 +288,17 @@ 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, + }) const customFields = layerHeaders .filter(({ dataKey }) => isValidUid(dataKey)) @@ -255,17 +308,16 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { name, dataKey, type, - renderer: getCustomFieldRenderer(type), + renderer: getCustomFieldRenderer(type, valueType), } }) 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 +353,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) => From bd7ea7ba2fc59e8a78b2132cde53dfeff463f6e2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 25 Jul 2026 14:57:07 +0200 Subject: [PATCH 05/17] chore: PR clean-up --- cypress/integration/dataTable.cy.js | 63 ++++++----- src/components/datatable/FilterInput.jsx | 104 ++++++++++++------ .../datatable/OrgUnitGroupFilterInput.jsx | 15 +-- .../datatable/__tests__/FilterInput.spec.jsx | 89 ++++++++++++++- src/util/__tests__/filter.spec.js | 16 +++ src/util/dateGroups.js | 7 +- src/util/filter.js | 23 +++- src/util/filterInput.js | 15 ++- src/util/orgUnitGroups.js | 4 +- 9 files changed, 256 insertions(+), 80 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 6e6bfc048..d1864f59a 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -85,8 +85,9 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 10) - // Filter by name - cy.getByDataTest('data-table-column-filter-search-Name') + // Filter by name (the "Name" column was renamed "Org unit" and moved + // to column 2 - "Org unit Id" (the row's own id) is now column 1) + cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') .type('bar{enter}') @@ -97,11 +98,11 @@ 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() + 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,10 @@ 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' }) + // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, + // Org unit level and Org unit hierarchy now precede it) + 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') @@ -209,8 +212,10 @@ 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 }) + // ("Org unit" moved from column 1 to column 3 - "Event Id" and the + // new "Org unit Id" column now precede it) + checkTableCell({ row: 0, column: 3, expectedContent: ouName }) + checkTableCell({ row: 2, column: 3, expectedContent: ouName }) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') @@ -317,7 +322,9 @@ 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' }) + // ("Name" is now the "Org unit" column, at index 2 - "Org unit Id" + // (the row's own id) is column 1) + 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 +333,17 @@ 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' }) + // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, + // Org unit level and Org unit hierarchy now precede it) + 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 +351,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 +367,9 @@ 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: '' }) + // ("Range" moved from column 8 to column 7: Value now precedes + // Legend/Range/Color instead of following Name/Id/Value/Level/Parent) + checkTableCell({ row: 0, column: 7, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -367,12 +378,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/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index d4cec63b3..f4733601b 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -119,10 +119,36 @@ 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 + + // For an org-unit-flavored column, the typed text is a name but the + // stored value is a raw path/id - resolve it to the matching raw values + // up front, so the filter itself is always raw-value based. That keeps + // matching consistent between the data table and every map layer, which + // filter this same `dataFilters` state independently (see filter.js's + // isOrgUnitValueFilter) and have no id->name resolution of their own. + 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 @@ -582,35 +608,47 @@ const FilterInput = React.memo(function FilterInput({ const isDateType = type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME - return isDateType ? ( - - ) : type === TYPE_ORG_UNIT ? ( - - ) : optionSetId ? ( - - ) : ( + if (isDateType) { + return ( + + ) + } + + if (type === TYPE_ORG_UNIT) { + return ( + + ) + } + + if (optionSetId) { + return ( + + ) + } + + return ( { + if (isOrgUnitGroupFilter(filterValue)) { + return filterValue.searchDerived ? filterValue.searchText : '' + } + return typeof filterValue === 'string' ? filterValue : '' +} + const OrgUnitGroupFilterInput = ({ dataKey, name, @@ -80,13 +87,7 @@ const OrgUnitGroupFilterInput = ({ isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived ? filterValue.prefixes : [] - const appliedString = isOrgUnitGroupFilter(filterValue) - ? filterValue.searchDerived - ? filterValue.searchText - : '' - : typeof filterValue === 'string' - ? filterValue - : '' + const appliedString = getAppliedString(filterValue) const anyValueActive = selectedPrefixes.includes(SENTINEL_ANY_VALUE) const notSetActive = selectedPrefixes.includes(SENTINEL_NO_VALUE) const treePrefixes = selectedPrefixes.filter( diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 6dbaf6f6c..06f5772fd 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -7,7 +7,10 @@ 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, +} from '../../../constants/dataTable.js' import useOptionSet from '../../../hooks/useOptionSet.js' import FilterInput from '../FilterInput.jsx' @@ -462,6 +465,90 @@ 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'], + ]) + + // "Org unit" (and any custom ORGANISATION_UNIT-valued field) stores a + // raw path/id but is filtered via the plain "Contains" box, unlike the + // tree-filterable "Org unit hierarchy" column - typing a name must still + // resolve to the matching raw value(s) up front, not commit the typed + // text itself, so that map layers (which match dataFilters against the + // raw stored value with no name resolution of their own) stay in sync + // with what the table shows. + 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/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index e07e6dec2..edcd9f3fd 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -248,6 +248,22 @@ describe('filterData', () => { 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', () => { diff --git a/src/util/dateGroups.js b/src/util/dateGroups.js index 5eca07fd9..ff9fd7eb4 100644 --- a/src/util/dateGroups.js +++ b/src/util/dateGroups.js @@ -1,6 +1,5 @@ import { TYPE_DATETIME, TYPE_TIME } from '../constants/dataTable.js' import { formatDate, formatDatetime } from './helpers.js' -import { togglePrefix } from './prefixTree.js' import { dateLocale } from './time.js' export { @@ -8,8 +7,8 @@ export { flattenVisibleNodes, getSearchMatches, nodeMatchesOrHasMatch, + togglePrefix as toggleDateGroupPrefix, } from './prefixTree.js' -export const toggleDateGroupPrefix = togglePrefix const DATE_KEY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})(?:([T ])(\d{2}))?/ const TIME_KEY_PATTERN = /^(\d{2}):/ @@ -18,11 +17,11 @@ export const parseDateGroupKey = (rawValue, granularity) => { const str = String(rawValue) if (granularity === TYPE_TIME) { - const match = str.match(TIME_KEY_PATTERN) + const match = TIME_KEY_PATTERN.exec(str) return match ? { hour: match[1] } : null } - const match = str.match(DATE_KEY_PATTERN) + const match = DATE_KEY_PATTERN.exec(str) if (!match) { return null } diff --git a/src/util/filter.js b/src/util/filter.js index 01e0ff2bb..78d6f1f89 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -34,6 +34,20 @@ export const isDateGroupFilter = (filter) => export const isOrgUnitGroupFilter = (filter) => isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY) +// A committed free-text search on an org-unit-flavored plain-text column +// (see FilterInput.jsx's applyCustomFilter) - the search text is resolved +// to matching raw stored values up front, at commit time, so the stored +// filter is always a plain list of raw values. That keeps matching +// consistent everywhere `filterData` is called (the data table AND every +// map layer, which filter the same `dataFilters` state independently and +// have no access to the id->name resolution used to interpret typed text). +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) { @@ -55,10 +69,15 @@ export const filterData = (data, filters) => { 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) || diff --git a/src/util/filterInput.js b/src/util/filterInput.js index bb7773b63..9a3dc75d0 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/orgUnitGroups.js b/src/util/orgUnitGroups.js index 51defde71..08d30cd6d 100644 --- a/src/util/orgUnitGroups.js +++ b/src/util/orgUnitGroups.js @@ -64,7 +64,7 @@ export const formatOrgUnitPathBreadcrumb = (path, idToName) => .join(' / ') export const formatOrgUnitOwnName = (path, idToName) => { - const leafId = String(path).split('/').filter(Boolean).pop() + const leafId = String(path).split('/').findLast(Boolean) return formatOrgUnitNodeLabel({ key: leafId }, idToName) } @@ -74,7 +74,7 @@ const collectOrgUnitMatches = (nodes, ancestors, options) => { const name = idToName?.get(node.key) const isMatch = node.prefix.toLowerCase().includes(normalizedSearch) || - (name && name.toLowerCase().includes(normalizedSearch)) + name?.toLowerCase().includes(normalizedSearch) if (isMatch) { result.matchedKeys.add(node.key) ancestors.forEach((key) => result.expandedAncestorKeys.add(key)) From 5dd10d82d02b32b3fbf45f731ca3fb4452abb60a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 25 Jul 2026 15:14:40 +0200 Subject: [PATCH 06/17] chore: GroupFilterPopover refactor --- .../datatable/DateGroupFilterInput.jsx | 249 +++------------- .../datatable/GroupFilterPopover.jsx | 269 ++++++++++++++++++ .../datatable/OrgUnitGroupFilterInput.jsx | 249 +++------------- 3 files changed, 341 insertions(+), 426 deletions(-) create mode 100644 src/components/datatable/GroupFilterPopover.jsx diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx index 02e91c43c..cf64ce4a5 100644 --- a/src/components/datatable/DateGroupFilterInput.jsx +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -1,15 +1,7 @@ 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, { useCallback, useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' -import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, @@ -27,31 +19,20 @@ import { } from '../../util/dateGroups.js' import { isDateGroupFilter } from '../../util/filter.js' import { - OPTION_ROW_HEIGHT, - MAX_LIST_HEIGHT, getCyclicIndex, getDisplayValue, toOptionIndex, - toHighlightedIndex, } from '../../util/filterInput.js' import { toggleAnyValue } from '../../util/filterSelection.js' -import Checkbox from '../core/Checkbox.jsx' -import { - FilterDropdownPopover, - getDropdownPlacement, -} from './FilterDropdownPopover.jsx' -import FilterHelpTooltip from './FilterHelpTooltip.jsx' -import styles from './styles/FilterInput.module.css' +import { getDropdownPlacement } from './FilterDropdownPopover.jsx' +import GroupFilterPopover from './GroupFilterPopover.jsx' -const DATE_GROUP_POPOVER_WIDTH = 220 -const HELP_HEIGHT = 56 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 INDENT_PX = 16 const DATE_INPUT_DISALLOWED = /[^0-9\-:. T]/g const DateGroupFilterInput = ({ @@ -288,198 +269,40 @@ const DateGroupFilterInput = ({ }) return ( -
- - { - 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 = formatNodeLabel( - node, - i18n.language - ) - return ( -
- {node.children.length > 0 ? ( - - ) : ( - - )} - - onToggleNode(node) - } - className={cx( - styles.denseCheckbox, - highlightedIndex === - toHighlightedIndex( - index, - showCustomFilterRow - ) && - styles.highlighted - )} - /> -
- ) - }} - /> - )} -
-
-
- )} -
+ formatNodeLabel(node, i18n.language)} + anchorRef={anchorRef} + listRef={listRef} + dropdownPlacement={dropdownPlacement} + dropdownSide={dropdownSide} + tooltipPlacement={tooltipPlacement} + isOpen={isOpen} + searchText={searchText} + highlightedIndex={highlightedIndex} + displayValue={displayValue} + visibleNodes={visibleNodes} + showCustomFilterRow={showCustomFilterRow} + anyValueActive={anyValueActive} + notSetActive={notSetActive} + hasNotSetOption={hasNotSetOption} + effectiveExpanded={effectiveExpanded} + checkStateFor={checkStateFor} + openPopover={openPopover} + closePopover={closePopover} + onSearchChange={onSearchChange} + onSearchKeyDown={onSearchKeyDown} + onApplyCustomFilterClick={() => { + applyCustomFilter(searchText.trim()) + closePopover() + }} + onToggleExpand={onToggleExpand} + onToggleNode={onToggleNode} + onToggleAnyValue={onToggleAnyValue} + onToggleNotSet={onToggleNotSet} + /> ) } diff --git a/src/components/datatable/GroupFilterPopover.jsx b/src/components/datatable/GroupFilterPopover.jsx new file mode 100644 index 000000000..36b02108d --- /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 index 1e0928df4..c1da43f87 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,15 +1,7 @@ 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, { useCallback, useMemo, useRef, useState } from 'react' import { useDispatch } from 'react-redux' -import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { SENTINEL_ANY_VALUE, @@ -19,12 +11,9 @@ import { import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' import { - OPTION_ROW_HEIGHT, - MAX_LIST_HEIGHT, getCyclicIndex, getDisplayValue, toOptionIndex, - toHighlightedIndex, } from '../../util/filterInput.js' import { toggleAnyValue } from '../../util/filterSelection.js' import { @@ -39,23 +28,15 @@ import { flattenVisibleNodes, nodeMatchesOrHasMatch, } from '../../util/prefixTree.js' -import Checkbox from '../core/Checkbox.jsx' -import { - FilterDropdownPopover, - getDropdownPlacement, -} from './FilterDropdownPopover.jsx' -import FilterHelpTooltip from './FilterHelpTooltip.jsx' -import styles from './styles/FilterInput.module.css' +import { getDropdownPlacement } from './FilterDropdownPopover.jsx' +import GroupFilterPopover from './GroupFilterPopover.jsx' -const ORG_UNIT_GROUP_POPOVER_WIDTH = 220 -const HELP_HEIGHT = 56 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 INDENT_PX = 16 const getAppliedString = (filterValue) => { if (isOrgUnitGroupFilter(filterValue)) { @@ -340,198 +321,40 @@ const OrgUnitGroupFilterInput = ({ }) return ( -
- - { - 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 = formatOrgUnitNodeLabel( - node, - idToName - ) - return ( -
- {node.children.length > 0 ? ( - - ) : ( - - )} - - onToggleNode(node) - } - className={cx( - styles.denseCheckbox, - highlightedIndex === - toHighlightedIndex( - index, - showCustomFilterRow - ) && - styles.highlighted - )} - /> -
- ) - }} - /> - )} -
-
-
- )} -
+ formatOrgUnitNodeLabel(node, idToName)} + anchorRef={anchorRef} + listRef={listRef} + dropdownPlacement={dropdownPlacement} + dropdownSide={dropdownSide} + tooltipPlacement={tooltipPlacement} + isOpen={isOpen} + searchText={searchText} + highlightedIndex={highlightedIndex} + displayValue={displayValue} + visibleNodes={visibleNodes} + showCustomFilterRow={showCustomFilterRow} + anyValueActive={anyValueActive} + notSetActive={notSetActive} + hasNotSetOption={hasNotSetOption} + effectiveExpanded={effectiveExpanded} + checkStateFor={checkStateFor} + openPopover={openPopover} + closePopover={closePopover} + onSearchChange={onSearchChange} + onSearchKeyDown={onSearchKeyDown} + onApplyCustomFilterClick={() => { + applyCustomFilter(searchText.trim()) + closePopover() + }} + onToggleExpand={onToggleExpand} + onToggleNode={onToggleNode} + onToggleAnyValue={onToggleAnyValue} + onToggleNotSet={onToggleNotSet} + /> ) } From b13f1fecd8251822576ab3a2473351e3fb5ec9ec Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 25 Jul 2026 15:30:39 +0200 Subject: [PATCH 07/17] chore: useGroupFilterInput refactor --- .../datatable/DateGroupFilterInput.jsx | 299 ++------------- .../datatable/OrgUnitGroupFilterInput.jsx | 355 +++--------------- .../datatable/useGroupFilterInput.js | 289 ++++++++++++++ 3 files changed, 377 insertions(+), 566 deletions(-) create mode 100644 src/components/datatable/useGroupFilterInput.js diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx index cf64ce4a5..3c7131c76 100644 --- a/src/components/datatable/DateGroupFilterInput.jsx +++ b/src/components/datatable/DateGroupFilterInput.jsx @@ -1,31 +1,16 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { 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, - DATE_GROUPS_GRANULARITY, -} from '../../constants/dataTable.js' +import React, { useCallback } from 'react' +import { setDataFilter } from '../../actions/dataFilters.js' +import { DATE_GROUPS_GRANULARITY } from '../../constants/dataTable.js' import { buildDateGroupTree, - flattenVisibleNodes, formatNodeLabel, - getNodeCheckState, getSearchMatches, - nodeMatchesOrHasMatch, - toggleDateGroupPrefix, } from '../../util/dateGroups.js' import { isDateGroupFilter } from '../../util/filter.js' -import { - getCyclicIndex, - getDisplayValue, - toOptionIndex, -} from '../../util/filterInput.js' -import { toggleAnyValue } from '../../util/filterSelection.js' -import { getDropdownPlacement } from './FilterDropdownPopover.jsx' import GroupFilterPopover from './GroupFilterPopover.jsx' +import useGroupFilterInput from './useGroupFilterInput.js' const HELP_CONTENT = (
@@ -35,6 +20,18 @@ const HELP_CONTENT = ( ) 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, @@ -43,265 +40,31 @@ const DateGroupFilterInput = ({ options, type, }) => { - 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 = isDateGroupFilter(filterValue) - ? filterValue.prefixes - : [] - const appliedString = typeof filterValue === 'string' ? 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: DATE_GROUPS_GRANULARITY, - prefixes: nextPrefixes, - }) - ) - : dispatch(clearDataFilter(layerId, dataKey)), - [dispatch, layerId, dataKey] - ) - - 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( - () => buildDateGroupTree(realValues, type), - [realValues, type] + const buildTree = useCallback( + (realValues) => buildDateGroupTree(realValues, type), + [type] ) - const normalizedSearch = searchText.trim().toLowerCase() - const searchMatches = useMemo( - () => - normalizedSearch ? getSearchMatches(tree, normalizedSearch) : null, - [tree, normalizedSearch] - ) - 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 = toggleDateGroupPrefix(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) => - text - ? dispatch(setDataFilter(layerId, dataKey, text)) - : dispatch(clearDataFilter(layerId, dataKey)) - - const onSearchChange = ({ value }) => { - const sanitized = value.replace(DATE_INPUT_DISALLOWED, '') - 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, + const groupFilter = useGroupFilterInput({ + dataKey, + layerId, + filterValue, + options, + granularity: DATE_GROUPS_GRANULARITY, + buildTree, + getMatches: getSearchMatches, + parseFilterValue, + commitSearch, + sanitizeInput, }) return ( formatNodeLabel(node, i18n.language)} - anchorRef={anchorRef} - listRef={listRef} - dropdownPlacement={dropdownPlacement} - dropdownSide={dropdownSide} - tooltipPlacement={tooltipPlacement} - isOpen={isOpen} - searchText={searchText} - highlightedIndex={highlightedIndex} - displayValue={displayValue} - visibleNodes={visibleNodes} - showCustomFilterRow={showCustomFilterRow} - anyValueActive={anyValueActive} - notSetActive={notSetActive} - hasNotSetOption={hasNotSetOption} - effectiveExpanded={effectiveExpanded} - checkStateFor={checkStateFor} - openPopover={openPopover} - closePopover={closePopover} - onSearchChange={onSearchChange} - onSearchKeyDown={onSearchKeyDown} - onApplyCustomFilterClick={() => { - applyCustomFilter(searchText.trim()) - closePopover() - }} - onToggleExpand={onToggleExpand} - onToggleNode={onToggleNode} - onToggleAnyValue={onToggleAnyValue} - onToggleNotSet={onToggleNotSet} /> ) } diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index c1da43f87..0641de422 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,35 +1,21 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useCallback, useMemo, useRef, useState } from 'react' -import { useDispatch } from 'react-redux' +import React, { useCallback, useMemo } from 'react' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' import { - SENTINEL_ANY_VALUE, - SENTINEL_NO_VALUE, ORG_UNIT_GROUPS_GRANULARITY, + SENTINEL_NO_VALUE, } from '../../constants/dataTable.js' import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' -import { - getCyclicIndex, - getDisplayValue, - toOptionIndex, -} from '../../util/filterInput.js' -import { toggleAnyValue } from '../../util/filterSelection.js' import { buildOrgUnitGroupTree, formatOrgUnitNodeLabel, getOrgUnitSearchMatches, } from '../../util/orgUnitGroups.js' -import { - getNodeCheckState, - togglePrefix, - flattenAllNodes, - flattenVisibleNodes, - nodeMatchesOrHasMatch, -} from '../../util/prefixTree.js' -import { getDropdownPlacement } from './FilterDropdownPopover.jsx' +import { flattenAllNodes } from '../../util/prefixTree.js' import GroupFilterPopover from './GroupFilterPopover.jsx' +import useGroupFilterInput from './useGroupFilterInput.js' const HELP_CONTENT = (
@@ -45,6 +31,14 @@ const getAppliedString = (filterValue) => { return typeof filterValue === 'string' ? filterValue : '' } +const parseFilterValue = (filterValue) => ({ + selectedPrefixes: + isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived + ? filterValue.prefixes + : [], + appliedString: getAppliedString(filterValue), +}) + const OrgUnitGroupFilterInput = ({ dataKey, name, @@ -52,57 +46,6 @@ const OrgUnitGroupFilterInput = ({ filterValue, options, }) => { - 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) - - // A committed free-text search is kept out of `selectedPrefixes` on - // purpose - like every other column's typed "Contains" filter, it - // narrows the table live but does not show any checkbox as checked - // (see applyCustomFilter below). - const selectedPrefixes = - isOrgUnitGroupFilter(filterValue) && !filterValue.searchDerived - ? filterValue.prefixes - : [] - const appliedString = getAppliedString(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: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: nextPrefixes, - }) - ) - : dispatch(clearDataFilter(layerId, dataKey)), - [dispatch, layerId, dataKey] - ) - - const hasNotSetOption = options.some( - ({ value }) => value === SENTINEL_NO_VALUE - ) const realValues = useMemo( () => options @@ -110,250 +53,66 @@ const OrgUnitGroupFilterInput = ({ .map((o) => o.value), [options] ) - - const tree = useMemo(() => buildOrgUnitGroupTree(realValues), [realValues]) - const { idToName } = useOrgUnitAncestorNames(realValues) - const nodeByKey = useMemo(() => { - const map = new Map() - flattenAllNodes(tree).forEach((node) => map.set(node.key, node)) - return map - }, [tree]) - - const normalizedSearch = searchText.trim().toLowerCase() - const searchMatches = useMemo( - () => - normalizedSearch - ? getOrgUnitSearchMatches(tree, normalizedSearch, idToName) - : null, - [tree, normalizedSearch, idToName] - ) - const effectiveExpanded = useMemo( - () => - searchMatches - ? new Set([ - ...expandedKeys, - ...searchMatches.expandedAncestorKeys, - ]) - : expandedKeys, - [expandedKeys, searchMatches] + const getMatches = useCallback( + (tree, normalizedSearch) => + getOrgUnitSearchMatches(tree, normalizedSearch, idToName), + [idToName] ) - 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] - ) - - // Unlike dates, an org unit's raw stored value is an id (or id path), - // never the human-readable name a user actually types here - matching - // "Contains" against that raw value would silently match nothing for - // any real-world search term. Committing free text instead narrows the - // table to every currently name/id-matched org unit - same live-as-you- - // type "Contains" semantics every other column's filter already has, - // dispatched with `searchDerived` so it (like every other column's - // typed filter) never shows as a checked box while typing. - const applyCustomFilter = (text) => { - const trimmed = text.trim() - if (!trimmed) { - dispatch(clearDataFilter(layerId, dataKey)) - return - } - const matches = getOrgUnitSearchMatches( - tree, - trimmed.toLowerCase(), - idToName - ) - const matchedPrefixes = [...matches.matchedKeys] - .map((key) => nodeByKey.get(key)) - .filter(Boolean) - .map((node) => node.prefix) - if (!matchedPrefixes.length) { - dispatch(clearDataFilter(layerId, dataKey)) - return - } - dispatch( - setDataFilter(layerId, dataKey, { - granularity: ORG_UNIT_GROUPS_GRANULARITY, - prefixes: matchedPrefixes, - searchDerived: true, - searchText: trimmed, - }) - ) - } - - const onSearchChange = ({ value }) => { - setSearchText(value) - setHighlightedIndex(-1) - - const trimmed = value.trim() - if (trimmed === '') { - if (hasActiveFilter) { - dispatch(clearDataFilter(layerId, dataKey)) + 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 } - 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 + dispatch( + setDataFilter(layerIdArg, dataKeyArg, { + granularity: ORG_UNIT_GROUPS_GRANULARITY, + prefixes: matchedPrefixes, + searchDerived: true, + searchText: text, }) - 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 - } - } + ) + }, + [idToName] + ) - const displayValue = getDisplayValue({ - isOpen, - searchText, - selected: selectedPrefixes, - appliedString, + const groupFilter = useGroupFilterInput({ + dataKey, + layerId, + filterValue, + options, + granularity: ORG_UNIT_GROUPS_GRANULARITY, + buildTree: buildOrgUnitGroupTree, + getMatches, + parseFilterValue, + commitSearch, }) return ( formatOrgUnitNodeLabel(node, idToName)} - anchorRef={anchorRef} - listRef={listRef} - dropdownPlacement={dropdownPlacement} - dropdownSide={dropdownSide} - tooltipPlacement={tooltipPlacement} - isOpen={isOpen} - searchText={searchText} - highlightedIndex={highlightedIndex} - displayValue={displayValue} - visibleNodes={visibleNodes} - showCustomFilterRow={showCustomFilterRow} - anyValueActive={anyValueActive} - notSetActive={notSetActive} - hasNotSetOption={hasNotSetOption} - effectiveExpanded={effectiveExpanded} - checkStateFor={checkStateFor} - openPopover={openPopover} - closePopover={closePopover} - onSearchChange={onSearchChange} - onSearchKeyDown={onSearchKeyDown} - onApplyCustomFilterClick={() => { - applyCustomFilter(searchText.trim()) - closePopover() - }} - onToggleExpand={onToggleExpand} - onToggleNode={onToggleNode} - onToggleAnyValue={onToggleAnyValue} - onToggleNotSet={onToggleNotSet} /> ) } diff --git a/src/components/datatable/useGroupFilterInput.js b/src/components/datatable/useGroupFilterInput.js new file mode 100644 index 000000000..4ccdc0d7e --- /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 From 71eadf9886a69e08c80f6c2551fdd63967d314ce Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 25 Jul 2026 16:49:43 +0200 Subject: [PATCH 08/17] fix: tracked entity datatable values resolution --- cypress/integration/dataTable.cy.js | 8 +- src/components/datatable/FilterInput.jsx | 5 + src/components/datatable/RowCells.jsx | 10 +- .../datatable/__tests__/FilterInput.spec.jsx | 24 +++ .../datatable/__tests__/useTableData.spec.jsx | 17 +- src/constants/dataTable.js | 1 + .../__tests__/trackedEntityLoader.spec.js | 117 ++++++++++++- src/loaders/trackedEntityLoader.js | 162 +++++++++++++++--- src/util/__tests__/tableHeaders.spec.js | 87 +++++++++- src/util/helpers.js | 2 +- src/util/tableHeaders.js | 45 ++++- 11 files changed, 439 insertions(+), 39 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index d1864f59a..d1446ee59 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -196,9 +196,11 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Check number of columns + // (+1 for the new "Last updated" column, sourced free from the + // analytics response's own lastupdated header - see tableHeaders.js) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 11) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -260,6 +262,10 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) + // NOTE: this column index predates this session's org-unit-column + // and "Last updated" column additions and was never re-verified + // against a live instance (no working local Cypress in this sandbox) + // - it is very likely stale. Re-check against a real run. checkTableCell({ row: 0, column: 7, expectedContent: '6' }) checkTableCell({ row: 1, column: 7, expectedContent: '32' }) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index f4733601b..5c76b81d6 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -13,6 +13,7 @@ import { RENDERER_ICON, RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, TYPE_NUMBER, TYPE_DATE, TYPE_DATETIME, @@ -39,6 +40,7 @@ import { toggleAnyValue, toggleRealValue, } from '../../util/filterSelection.js' +import { formatBoolean } from '../../util/helpers.js' import { formatWithSeparator } from '../../util/numbers.js' import { formatOrgUnitPathBreadcrumb, @@ -535,6 +537,9 @@ const PlainSearchableFilter = (props) => { if (renderer === RENDERER_ORG_UNIT_NAME) { return formatOrgUnitOwnName(value, orgUnitIdToName) } + if (renderer === RENDERER_BOOLEAN) { + return formatBoolean(value) + } return type === TYPE_NUMBER ? formatWithSeparator( Number(value), diff --git a/src/components/datatable/RowCells.jsx b/src/components/datatable/RowCells.jsx index cb5d434ca..b0ef0b2fb 100644 --- a/src/components/datatable/RowCells.jsx +++ b/src/components/datatable/RowCells.jsx @@ -8,12 +8,17 @@ import { 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, @@ -88,6 +93,7 @@ const RowCells = ({ 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 ( { 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 - only the checkbox label is reformatted for display. + fireEvent.click(yes) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'followUp', + filter: ['1'], + }) + }) }) describe('FilterInput multi-select path (optionSetId)', () => { diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index b085b4bb7..8ad2e769d 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -594,9 +594,14 @@ describe('useTableData headers', () => { renderer: 'renderdate', }, { - name: 'Last updated on', + // A fixed column now, not a coincidental customFields match + // (see tableHeaders.js's fixedDataKeys exclusion) - the raw + // analytics header's own "Last updated on" label is no + // longer used, this is the same fixed name/type/renderer + // Tracked Entity's "Last updated" column uses. + name: 'Last updated', dataKey: 'lastupdated', - type: 'date', + type: 'datetime', renderer: 'renderdate', }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, @@ -721,7 +726,7 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(9) + expect(headers).toHaveLength(11) expect(headers).toMatchObject([ { name: 'Tracked entity Id', dataKey: 'id', type: 'string' }, { name: 'Org unit Id', dataKey: 'orgUnitId', type: 'string' }, @@ -732,19 +737,23 @@ describe('useTableData headers', () => { 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(9) + 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' }, diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index f08391810..7dea40733 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -10,6 +10,7 @@ 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' diff --git a/src/loaders/__tests__/trackedEntityLoader.spec.js b/src/loaders/__tests__/trackedEntityLoader.spec.js index 29f955c4f..0968ca132 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', () => { @@ -174,4 +247,44 @@ describe('toGeoJson', () => { 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/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index 7bdb84e54..c91e37826 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -20,10 +20,22 @@ import { } 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', 'orgUnit'] +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([ @@ -104,25 +116,41 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -export const getAttributeProperties = (attributes) => +// Resolves an option-set-coded attribute value to its display name, mirroring +// the load-time resolution eventLoader.js/util/geojson.js already does for +// events (via the analytics response's metaData.items) - option codes never +// come with a name attached on tracker/trackedEntities' attribute values, so +// the caller must fetch and pass the code->name lookups separately (see +// fetchOptionSetIdByAttribute/fetchOptionNamesByOptionSet below). +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, }) } }) @@ -132,18 +160,90 @@ 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, orgUnit }) => ({ - type: GEO_TYPE_FEATURE, - geometry, - properties: { - id, - color, - orgUnit, - type: geometry?.type, - ...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 + ), + }, + }) + ) + +// Learns each attribute's option set id from trackedEntityType/program +// metadata - tracker/trackedEntities' own attribute values never carry it +// (optionSet lives on the trackedEntityAttribute metadata object, a separate +// resource). Same query constants and merge-by-id logic as +// TrackedEntityLayer.jsx's loadDisplayAttributes, reused here for the data +// table instead of the map popup/marker display. +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]) + ) +} + +// Bulk-fetches each distinct option set's code->name lookup, only for option +// sets actually referenced by attributes present in the loaded instances. +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 } = @@ -355,7 +455,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 @@ -383,7 +494,10 @@ const trackedEntityLoader = async ({ legend, })) } else { - data = toGeoJson(instances, pointColor) + data = toGeoJson(instances, pointColor, { + optionSetIdByAttribute, + optionNamesByOptionSet, + }) } data = await attachOrgUnitPaths( diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index 8a322f1c9..705c90413 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -1,4 +1,8 @@ -import { RENDERER_DATE, RENDERER_ORG_UNIT } from '../../constants/dataTable.js' +import { + RENDERER_DATE, + RENDERER_ORG_UNIT, + RENDERER_BOOLEAN, +} from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -137,6 +141,11 @@ describe('getHeadersForLayer - event', () => { column: 'Referred by facility', valueType: 'ORGANISATION_UNIT', }, + { + name: 'd4e5f6a7b8c', + column: 'Follow-up', + valueType: 'BOOLEAN', + }, ] const result = getHeadersForLayer(EVENT_LAYER, { layerHeaders }) const headerFor = (dataKey) => @@ -153,12 +162,31 @@ describe('getHeadersForLayer - event', () => { // filter from, so it stays plain text. The cell renderer still // applies (a harmless no-op here, since the value is already a name). expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) + // A boolean also stays plain text - its 2-3 distinct raw values + // already drive a sensible checkbox filter; only the renderer + // changes, to format cells/checkbox labels as Yes/No. + 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', () => { @@ -171,6 +199,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, { @@ -233,6 +280,8 @@ describe('getHeadersForLayer - tracked entity', () => { 'orgUnitOwn', 'level', 'orgUnitPath', + 'createdAt', + 'updatedAt', 'w75KJ2mc4zz', 'color', 'type', @@ -241,9 +290,19 @@ describe('getHeadersForLayer - tracked entity', () => { (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', @@ -261,6 +320,11 @@ describe('getHeadersForLayer - tracked entity', () => { dataKey: 'c3d4e5f6a7b', valueType: 'ORGANISATION_UNIT', }, + { + name: 'Follow-up', + dataKey: 'd4e5f6a7b8c', + valueType: 'BOOLEAN', + }, ] const result = getHeadersForLayer(TRACKED_ENTITY_LAYER, { layerHeaders, @@ -274,10 +338,29 @@ describe('getHeadersForLayer - tracked entity', () => { // Plain text now (no tree filter), but the cell renderer still // resolves the tracker API's raw bare id to a readable name. 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) }) }) diff --git a/src/util/helpers.js b/src/util/helpers.js index 3ada5f983..f31544291 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/tableHeaders.js b/src/util/tableHeaders.js index c12df0911..a88132c47 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -5,6 +5,7 @@ import { RENDERER_DATE, RENDERER_ORG_UNIT, RENDERER_ORG_UNIT_NAME, + RENDERER_BOOLEAN, TYPE_NUMBER, TYPE_STRING, TYPE_DATE, @@ -31,6 +32,7 @@ import { datetimeValueTypes, timeValueTypes, ouValueTypes, + booleanValueTypes, } from '../constants/valueTypes.js' import { hasClasses } from './earthEngine.js' import { getGeojsonDisplayData } from './geojson.js' @@ -81,6 +83,9 @@ const getCustomFieldRenderer = (type, valueType) => { if (ouValueTypes.includes(valueType)) { return RENDERER_ORG_UNIT } + if (booleanValueTypes.includes(valueType)) { + return RENDERER_BOOLEAN + } return undefined } @@ -95,6 +100,9 @@ const GROUP = 'group' const ICON = 'iconUrl' 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 @@ -140,6 +148,24 @@ const defaultFieldsMap = () => ({ 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, @@ -240,14 +266,22 @@ const getEventHeaders = ({ }) => { const fields = getOrgUnitCoreFields(i18n.t('Event Id'), { includeOrgUnitId: true, - }).concat(defaultFieldsMap()[EVENTDATE]) + }) + .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, the same shape + // isValidUid checks for - excluding whatever dataKey a fixed field above + // already claims prevents a coincidental duplicate column. + 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 { @@ -299,16 +333,19 @@ const getTrackedEntityHeaders = ({ layerHeaders = [] }) => { 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, valueType), + optionSet: optionSet || null, } }) From c7f2f65ec6b6933c8a9bdaa3bbae9644259d860f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sat, 25 Jul 2026 21:30:37 +0200 Subject: [PATCH 09/17] chore: cypress tests fix --- cypress/integration/dataTable.cy.js | 12 ++++-- src/components/datatable/useTableData.js | 8 +++- src/util/__tests__/tableSort.spec.js | 53 ++++++++++++++++++++++++ src/util/tableSort.js | 28 +++++++++++-- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index d1446ee59..fc79b808c 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -195,12 +195,16 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // Check number of columns - // (+1 for the new "Last updated" column, sourced free from the - // analytics response's own lastupdated header - see tableHeaders.js) + // Check number of columns - live-verified against CI (13), not + // hand-derived: besides the 3 displayInReports data elements, at + // least "eventstatus" (11 letters) also coincidentally matches + // isValidUid's UID-shape regex and slips through as a custom field + // (see tableHeaders.js's fixedDataKeys exclusion, which only + // de-dupes a name already claimed by a fixed column like + // "lastupdated" - it doesn't stop every 11-letter coincidence). cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 11) + .should('have.length', 13) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 04e37c891..fa2b12dbe 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -305,7 +305,13 @@ export const useTableData = ({ // Sort 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)) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js index 76cf35552..ffb1b05f2 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,57 @@ describe('compareFieldValues', () => { }) ).toBeGreaterThan(0) }) + + describe('org-unit-renderer columns - sorts by the resolved display name, not the raw stored path/id', () => { + // Deliberately opposite of alphabetical-by-name, so a test that + // still passed on the raw id would prove the fix does nothing. + 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', () => { + // Raw ids alone would sort the other way ("aFacility" < "zFacility") + 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/tableSort.js b/src/util/tableSort.js index ac53e00f6..ad8c8725b 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,24 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => { const isNoValue = (val) => val === undefined || val === null +// An org-unit-renderer column's raw stored value is a path/id, not the name +// actually displayed in the cell - sorting by the raw value would order rows +// by that path/id instead of what's shown. Resolve it the same way the cell +// itself does (DataTable.jsx) before comparing. +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 +100,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) => { From 68b76a7161f3d5e0045003b321f128d2f9ffc2fc Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Jul 2026 10:47:42 +0200 Subject: [PATCH 10/17] chore: fix cypress tests --- cypress/integration/dataTable.cy.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index fc79b808c..15324f2b7 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -101,7 +101,8 @@ describe('data table', () => { checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) - // Sort by name + // 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 @@ -270,8 +271,8 @@ describe('data table', () => { // and "Last updated" column additions and was never re-verified // against a live instance (no working local Cypress in this sandbox) // - it is very likely stale. Re-check against a real run. - 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') From 0b6326eaa6bb17aaaae0f39d6af389d01a057487 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Jul 2026 16:59:29 +0200 Subject: [PATCH 11/17] chore: PR round-up --- cypress/integration/dataTable.cy.js | 2 +- .../datatable/__tests__/useTableData.spec.jsx | 326 ------------------ .../datatable/styles/BottomPanel.module.css | 51 --- .../datatable/styles/DataTable.module.css | 1 + src/components/datatable/useTableData.js | 3 + 5 files changed, 5 insertions(+), 378 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 15324f2b7..3ea679b15 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -165,7 +165,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() diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 8ad2e769d..439cfe5d0 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2252,329 +2252,3 @@ describe('useTableData globalSearch', () => { 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, - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' - ) - }) - - test('excludes features without geometry when the toggle is on', () => { - const layerWithoutCoords = { - ...layer, - data: [layer.data[0]], - dataWithoutCoords: [ - { - id: 'nogeom', - properties: { id: 'nogeom', name: 'No geometry' }, - geometry: null, - }, - ], - } - - 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' - ) - }) -}) - -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']), - }) - expect(current.rows).toHaveLength(2) - }) - - 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' - ) - }) - - 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']), - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item B' - ) - }) - - 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(), - }) - 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, - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'In view' - ) - }) - - test('excludes features without geometry when the toggle is on', () => { - const layerWithoutCoords = { - ...layer, - data: [layer.data[0]], - dataWithoutCoords: [ - { - id: 'nogeom', - properties: { id: 'nogeom', name: 'No geometry' }, - geometry: null, - }, - ], - } - - 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' - ) - }) -}) - -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']), - }) - expect(current.rows).toHaveLength(2) - }) - - 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' - ) - }) - - 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']), - }) - expect(current.rows).toHaveLength(1) - expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( - 'Item B' - ) - }) - - 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(), - }) - expect(current.rows).toHaveLength(0) - }) -}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 5f23ec511..e0002b070 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 416b4a55d..8bd794ee9 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/useTableData.js b/src/components/datatable/useTableData.js index fa2b12dbe..61ff6f31a 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -304,6 +304,9 @@ export const useTableData = ({ } // Sort + const sortFieldRenderer = headers.find( + (h) => h.dataKey === sortField + )?.renderer filteredData.sort((a, b) => compareRows(a, b, { sortField, From ac4a35a342c22a83aea8cdbcfd71f75ae3d3d23d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Jul 2026 18:05:12 +0200 Subject: [PATCH 12/17] chore: PR clean-up --- cypress/integration/dataTable.cy.js | 25 ++----------------- src/components/datatable/FilterInput.jsx | 6 ----- .../datatable/__tests__/FilterInput.spec.jsx | 10 +------- .../datatable/__tests__/useTableData.spec.jsx | 5 ---- src/components/datatable/useTableData.js | 5 ---- src/constants/dataTable.js | 5 ---- .../__tests__/useOrgUnitAncestorNames.spec.js | 3 --- src/hooks/useOrgUnitAncestorNames.js | 10 ++------ src/loaders/eventLoader.js | 3 --- src/loaders/trackedEntityLoader.js | 14 ----------- src/util/__tests__/tableHeaders.spec.js | 10 -------- src/util/__tests__/tableSort.spec.js | 3 --- src/util/dateGroups.js | 7 ------ src/util/filter.js | 7 ------ src/util/orgUnitGroups.js | 16 ------------ src/util/tableHeaders.js | 20 ++------------- src/util/tableSort.js | 4 --- 17 files changed, 7 insertions(+), 146 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 3ea679b15..cfc369841 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -85,8 +85,7 @@ describe('data table', () => { .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 10) - // Filter by name (the "Name" column was renamed "Org unit" and moved - // to column 2 - "Org unit Id" (the row's own id) is now column 1) + // Filter by Org unit cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') .type('bar{enter}') @@ -132,8 +131,6 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that the rows are sorted by Value ascending - // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, - // Org unit level and Org unit hierarchy now precede it) checkTableCell({ row: 0, column: 5, expectedContent: '35' }) checkTableCell({ row: 4, column: 5, expectedContent: '76' }) @@ -196,13 +193,7 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // Check number of columns - live-verified against CI (13), not - // hand-derived: besides the 3 displayInReports data elements, at - // least "eventstatus" (11 letters) also coincidentally matches - // isValidUid's UID-shape regex and slips through as a custom field - // (see tableHeaders.js's fixedDataKeys exclusion, which only - // de-dupes a name already claimed by a fixed column like - // "lastupdated" - it doesn't stop every 11-letter coincidence). + // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') .should('have.length', 13) @@ -219,8 +210,6 @@ describe('data table', () => { .type(`${ouName}{enter}`) // Check that all the rows have Org unit Moyowa - // ("Org unit" moved from column 1 to column 3 - "Event Id" and the - // new "Org unit Id" column now precede it) checkTableCell({ row: 0, column: 3, expectedContent: ouName }) checkTableCell({ row: 2, column: 3, expectedContent: ouName }) @@ -267,10 +256,6 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) - // NOTE: this column index predates this session's org-unit-column - // and "Last updated" column additions and was never re-verified - // against a live instance (no working local Cypress in this sandbox) - // - it is very likely stale. Re-check against a real run. checkTableCell({ row: 0, column: 10, expectedContent: '6' }) checkTableCell({ row: 1, column: 10, expectedContent: '32' }) @@ -333,8 +318,6 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Confirm that the sort order is initially ascending by Name - // ("Name" is now the "Org unit" column, at index 2 - "Org unit Id" - // (the row's own id) is column 1) checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending @@ -344,8 +327,6 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that first row has Tihun CHC with value 28.63 - // ("Value" moved from column 3 to column 5: Org unit Id, Org unit, - // Org unit level and Org unit hierarchy now precede it) checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) checkTableCell({ row: 0, column: 5, expectedContent: '28.63' }) @@ -378,8 +359,6 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value is empty - // ("Range" moved from column 8 to column 7: Value now precedes - // Legend/Range/Color instead of following Name/Id/Value/Level/Parent) checkTableCell({ row: 0, column: 7, expectedContent: '' }) // Sort by range, which is a string diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 5c76b81d6..eb6097cdd 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -124,12 +124,6 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({ const isOrgUnitRenderer = renderer === RENDERER_ORG_UNIT || renderer === RENDERER_ORG_UNIT_NAME - // For an org-unit-flavored column, the typed text is a name but the - // stored value is a raw path/id - resolve it to the matching raw values - // up front, so the filter itself is always raw-value based. That keeps - // matching consistent between the data table and every map layer, which - // filter this same `dataFilters` state independently (see filter.js's - // isOrgUnitValueFilter) and have no id->name resolution of their own. const applyCustomFilter = (text) => { if (!text) { dispatch(clearDataFilter(layerId, dataKey)) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx index 6d489a78d..19fe75101 100644 --- a/src/components/datatable/__tests__/FilterInput.spec.jsx +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -257,8 +257,7 @@ describe('FilterInput multi-select path (no optionSetId)', () => { const no = screen.getByLabelText('No') expect(yes).toBeInTheDocument() expect(no).toBeInTheDocument() - // The underlying dispatched filter value stays the raw stored - // string - only the checkbox label is reformatted for display. + // The underlying dispatched filter value stays the raw stored string fireEvent.click(yes) expect(store.getActions()).toContainEqual({ type: DATA_FILTER_SET, @@ -496,13 +495,6 @@ describe('FilterInput searchable popover — org-unit-flavored plain-text column ['facility2', 'Tihun CHC'], ]) - // "Org unit" (and any custom ORGANISATION_UNIT-valued field) stores a - // raw path/id but is filtered via the plain "Contains" box, unlike the - // tree-filterable "Org unit hierarchy" column - typing a name must still - // resolve to the matching raw value(s) up front, not commit the typed - // text itself, so that map layers (which match dataFilters against the - // raw stored value with no name resolution of their own) stay in sync - // with what the table shows. test('resolves typed text to the matching raw value(s), not the raw typed text', () => { const { store } = renderFilterInput({ dataKey: 'orgUnitOwn', diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 439cfe5d0..83d85a83e 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -594,11 +594,6 @@ describe('useTableData headers', () => { renderer: 'renderdate', }, { - // A fixed column now, not a coincidental customFields match - // (see tableHeaders.js's fixedDataKeys exclusion) - the raw - // analytics header's own "Last updated on" label is no - // longer used, this is the same fixed name/type/renderer - // Tracked Entity's "Last updated" column uses. name: 'Last updated', dataKey: 'lastupdated', type: 'datetime', diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 61ff6f31a..c7751237f 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -241,11 +241,6 @@ export const useTableData = ({ return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS }, [columnDistinctValues, sortField, sortDirection]) - // Every column whose cell needs an id/path resolved to a readable name - - // "Org unit hierarchy" (tree-filterable) plus "Org unit" and any custom - // ORGANISATION_UNIT-valued field (plain-text filterable, but their - // cells still resolve for display) - keyed by renderer rather than - // type, since only the hierarchy column is still TYPE_ORG_UNIT. const orgUnitPathValues = useMemo( () => (headers ?? []) diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js index 7dea40733..ea8e69558 100644 --- a/src/constants/dataTable.js +++ b/src/constants/dataTable.js @@ -22,12 +22,7 @@ export const TYPE_ORG_UNIT = 'orgUnit' export const DATE_GROUPS_GRANULARITY = 'date-groups' export const ORG_UNIT_GROUPS_GRANULARITY = 'org-unit-groups' -// Full ancestor path (breadcrumb renderer) - "Org unit hierarchy" column export const ORG_UNIT_PATH_DATA_KEY = 'orgUnitPath' -// Same path value as ORG_UNIT_PATH_DATA_KEY, rendered as the leaf name only - "Org unit" column export const ORG_UNIT_DATA_KEY = 'orgUnitOwn' -// The layer's own org unit's bare id - "Org unit Id" column (Event/Tracked entity layers only, -// whose own "Id" field is the event/tracked-entity id, not the org unit id) export const ORG_UNIT_ID_DATA_KEY = 'orgUnitId' -// The org unit's own hierarchy depth (1 = country, 2 = region, ...) - "Org unit level" column export const ORG_UNIT_LEVEL_DATA_KEY = 'level' diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js index 654ef013f..ca5dba33f 100644 --- a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -2,9 +2,6 @@ import { renderHook, waitFor } from '@testing-library/react' import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js' import useOrgUnitAncestorNames from '../useOrgUnitAncestorNames.js' -// A stable reference, matching the real useDataEngine's contract - an -// unstable mock (a fresh object per call) would retrigger the hook's effect -// on every state update it causes, since `engine` is one of its deps jest.mock('@dhis2/app-runtime', () => ({ useDataEngine: () => mockEngine, })) diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js index ab66624c7..32d35e3d4 100644 --- a/src/hooks/useOrgUnitAncestorNames.js +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -2,12 +2,6 @@ import { useDataEngine } from '@dhis2/app-runtime' import { useEffect, useMemo, useState } from 'react' import { fetchOrgUnitPathDetails } from '../util/orgUnits.js' -// Resolves the distinct ancestor ids across a set of org-unit path values -// (e.g. '/ImspTQPwCqd/O6uvpzGd5pu') to real display names, batched in one -// bulk request. Ids are not human-readable on their own - unlike the date -// tree, an org unit's raw value doesn't self-describe its label. Callers -// (the table cell renderer and OrgUnitGroupFilterInput.jsx) render the raw -// id as a placeholder until `idToName` resolves, rather than blocking. const useOrgUnitAncestorNames = (distinctPathValues) => { const engine = useDataEngine() const ids = useMemo( @@ -43,8 +37,8 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { return () => { cancelled = true } - // idsKey is the stable, content-based dependency - `ids` is a new - // array identity every render + // 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]) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 6df82b4db..f2722f0ee 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -47,9 +47,6 @@ import { isValidUid } from '../util/uid.js' const getEventOuId = (feature) => feature.properties?.ou ?? feature.properties?.['Organisation unit'] -// Attaches each event's org unit ancestor path (data table "Org unit -// hierarchy" column) - see util/orgUnits.js's attachOrgUnitPaths, shared -// with trackedEntityLoader.js. export const attachOrgUnitPaths = async ({ config, engine }) => { if (!config.data?.length) { return diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index c91e37826..0311742dc 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -116,12 +116,6 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -// Resolves an option-set-coded attribute value to its display name, mirroring -// the load-time resolution eventLoader.js/util/geojson.js already does for -// events (via the analytics response's metaData.items) - option codes never -// come with a name attached on tracker/trackedEntities' attribute values, so -// the caller must fetch and pass the code->name lookups separately (see -// fetchOptionSetIdByAttribute/fetchOptionNamesByOptionSet below). export const getAttributeProperties = ( attributes, optionSetIdByAttribute, @@ -185,12 +179,6 @@ export const toGeoJson = ( }) ) -// Learns each attribute's option set id from trackedEntityType/program -// metadata - tracker/trackedEntities' own attribute values never carry it -// (optionSet lives on the trackedEntityAttribute metadata object, a separate -// resource). Same query constants and merge-by-id logic as -// TrackedEntityLayer.jsx's loadDisplayAttributes, reused here for the data -// table instead of the map popup/marker display. const fetchOptionSetIdByAttribute = async ( engine, { trackedEntityType, program } @@ -226,8 +214,6 @@ const fetchOptionSetIdByAttribute = async ( ) } -// Bulk-fetches each distinct option set's code->name lookup, only for option -// sets actually referenced by attributes present in the loaded instances. const fetchOptionNamesByOptionSet = async (engine, optionSetIds) => { const entries = await Promise.all( optionSetIds.map(async (id) => { diff --git a/src/util/__tests__/tableHeaders.spec.js b/src/util/__tests__/tableHeaders.spec.js index 705c90413..a8d175a45 100644 --- a/src/util/__tests__/tableHeaders.spec.js +++ b/src/util/__tests__/tableHeaders.spec.js @@ -156,15 +156,7 @@ describe('getHeadersForLayer - event', () => { expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) expect(typeOf('a1b2c3d4e5f')).toBe(TYPE_DATE) expect(typeOf('b2c3d4e5f6a')).toBe(TYPE_STRING) - // Unlike a tracked entity attribute, the events analytics query - // always resolves an ORGANISATION_UNIT-valued data element to its - // display name server-side - there's no id left to build a tree - // filter from, so it stays plain text. The cell renderer still - // applies (a harmless no-op here, since the value is already a name). expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) - // A boolean also stays plain text - its 2-3 distinct raw values - // already drive a sensible checkbox filter; only the renderer - // changes, to format cells/checkbox labels as Yes/No. expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) expect(headerFor('zDhUuAYrxNC').renderer).toBe(RENDERER_DATE) @@ -335,8 +327,6 @@ describe('getHeadersForLayer - tracked entity', () => { expect(typeOf('w75KJ2mc4zz')).toBe(TYPE_DATE) expect(typeOf('zDhUuAYrxNC')).toBe(TYPE_DATETIME) expect(typeOf('oZg33kd9taw')).toBe(TYPE_TIME) - // Plain text now (no tree filter), but the cell renderer still - // resolves the tracker API's raw bare id to a readable name. expect(typeOf('c3d4e5f6a7b')).toBe(TYPE_STRING) expect(typeOf('d4e5f6a7b8c')).toBe(TYPE_STRING) expect(headerFor('w75KJ2mc4zz').renderer).toBe(RENDERER_DATE) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js index ffb1b05f2..746b4abd4 100644 --- a/src/util/__tests__/tableSort.spec.js +++ b/src/util/__tests__/tableSort.spec.js @@ -80,8 +80,6 @@ describe('compareFieldValues', () => { }) describe('org-unit-renderer columns - sorts by the resolved display name, not the raw stored path/id', () => { - // Deliberately opposite of alphabetical-by-name, so a test that - // still passed on the raw id would prove the fix does nothing. const idToName = new Map([ ['country1', 'Sierra Leone'], ['zFacility', 'Bargbe'], @@ -89,7 +87,6 @@ describe('compareFieldValues', () => { ]) it('RENDERER_ORG_UNIT_NAME: compares the resolved leaf name, not the raw id', () => { - // Raw ids alone would sort the other way ("aFacility" < "zFacility") expect( compareFieldValues( '/country1/aFacility', diff --git a/src/util/dateGroups.js b/src/util/dateGroups.js index ff9fd7eb4..93b7fa0c8 100644 --- a/src/util/dateGroups.js +++ b/src/util/dateGroups.js @@ -46,13 +46,6 @@ const getOrCreateNode = (childMap, { key, level, label }) => { return node } -// Preserves encounter order rather than re-sorting: buildDateGroupTree's -// caller (DateGroupFilterInput.jsx) always receives values already ordered -// to match the column's current sort direction (see useTableData.js's -// columnOptions) - walking them in that order naturally reproduces the same -// ascending/descending order at every level of the tree, so the popover's -// checkbox order stays consistent with the column header's sort, just like -// every other filter popover's option list already does. const sortedNodes = (childMap) => Array.from(childMap.values()).map((node) => ({ key: node.key, diff --git a/src/util/filter.js b/src/util/filter.js index 78d6f1f89..9b8fc30f6 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -34,13 +34,6 @@ export const isDateGroupFilter = (filter) => export const isOrgUnitGroupFilter = (filter) => isPrefixGroupFilter(filter, ORG_UNIT_GROUPS_GRANULARITY) -// A committed free-text search on an org-unit-flavored plain-text column -// (see FilterInput.jsx's applyCustomFilter) - the search text is resolved -// to matching raw stored values up front, at commit time, so the stored -// filter is always a plain list of raw values. That keeps matching -// consistent everywhere `filterData` is called (the data table AND every -// map layer, which filter the same `dataFilters` state independently and -// have no access to the id->name resolution used to interpret typed text). export const isOrgUnitValueFilter = (filter) => filter != null && typeof filter === 'object' && diff --git a/src/util/orgUnitGroups.js b/src/util/orgUnitGroups.js index 08d30cd6d..3e7e2657b 100644 --- a/src/util/orgUnitGroups.js +++ b/src/util/orgUnitGroups.js @@ -7,13 +7,6 @@ const getOrCreateNode = (childMap, { key, prefix, ouLevel }) => { return node } -// Preserves encounter order rather than re-sorting: buildOrgUnitGroupTree's -// caller (OrgUnitGroupFilterInput.jsx) always receives pathValues already -// ordered to match the column's current sort direction (see useTableData.js's -// columnOptions) - walking them in that order naturally reproduces the same -// ascending/descending order at every level of the tree, so the popover's -// checkbox order stays consistent with the column header's sort, just like -// every other filter popover's option list already does. const sortedNodes = (childMap) => Array.from(childMap.values()).map((node) => ({ key: node.key, @@ -23,15 +16,6 @@ const sortedNodes = (childMap) => children: sortedNodes(node.childMap), })) -// Builds an ancestor-path tree (Country -> Region -> District -> Facility, -// or however many levels a given path has) from a column's flat distinct -// full-path values (e.g. '/ImspTQPwCqd/O6uvpzGd5pu/lc3eMKXaEfw'). Unlike -// dateGroups.js's tree, an org unit's own id is naturally the tree's leaf - -// no separate terminal "value" node is needed, since the path's last -// segment already is the selectable unit. `name` starts null on every node; -// callers resolve it asynchronously and re-render (see -// src/hooks/useOrgUnitAncestorNames.js), falling back to the raw id label -// until then. export const buildOrgUnitGroupTree = (pathValues) => { const rootMap = new Map() diff --git a/src/util/tableHeaders.js b/src/util/tableHeaders.js index a88132c47..de376360b 100644 --- a/src/util/tableHeaders.js +++ b/src/util/tableHeaders.js @@ -41,14 +41,6 @@ import { isValidUid } from './uid.js' export { TYPE_NUMBER, TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_TIME } -// A custom ORGANISATION_UNIT-valued field is always plain text, on both -// Event and Tracked Entity layers: the events analytics query always -// resolves it to a display name server-side (a hardcoded `_name` column -// select - no outputIdScheme param can change this), and tracker attribute -// values are a bare id with no ancestor chain to reverse-resolve safely -// (org unit names aren't guaranteed unique). Either way there's no reliable -// path/ancestor data to build a tree filter from - only "Org unit -// hierarchy" (the layer's own org unit) gets that treatment. const getCustomFieldType = (valueType, hasOptionSet) => { if (hasOptionSet) { return TYPE_STRING @@ -70,12 +62,6 @@ const getCustomFieldType = (valueType, hasOptionSet) => { const DATE_LIKE_TYPES = new Set([TYPE_DATE, TYPE_DATETIME, TYPE_TIME]) -// Keyed off valueType (not the column's TYPE_STRING type) so an -// ORGANISATION_UNIT-valued field's cell still resolves to a readable name: -// a real id->name lookup for tracker-sourced (Tracked Entity) values, and a -// harmless no-op for analytics-sourced (Event) values that are already a -// name (formatOrgUnitPathBreadcrumb falls back to the raw string when it -// finds no matching id in idToName). const getCustomFieldRenderer = (type, valueType) => { if (DATE_LIKE_TYPES.has(type)) { return RENDERER_DATE @@ -274,10 +260,8 @@ const getEventHeaders = ({ fields.push(defaultFieldsMap()[OUBOUNDARY]) } - // A handful of the analytics response's own fixed column names (e.g. - // "lastupdated", "eventstatus") happen to be 11 letters, the same shape - // isValidUid checks for - excluding whatever dataKey a fixed field above - // already claims prevents a coincidental duplicate column. + // 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 diff --git a/src/util/tableSort.js b/src/util/tableSort.js index ad8c8725b..8c11ea26e 100644 --- a/src/util/tableSort.js +++ b/src/util/tableSort.js @@ -65,10 +65,6 @@ export const compareRangeValues = (aVal, bVal, sortDirection) => { const isNoValue = (val) => val === undefined || val === null -// An org-unit-renderer column's raw stored value is a path/id, not the name -// actually displayed in the cell - sorting by the raw value would order rows -// by that path/id instead of what's shown. Resolve it the same way the cell -// itself does (DataTable.jsx) before comparing. const resolveSortText = (value, renderer, idToName) => { if (renderer === RENDERER_ORG_UNIT) { return formatOrgUnitPathBreadcrumb(value, idToName) From 42a754d073139214e509efb9a0b4e213f6a11872 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Jul 2026 20:20:45 +0200 Subject: [PATCH 13/17] fix: resolve org unit names using display-name setting and drop duplicate lookup fetch --- src/components/datatable/FilterInput.jsx | 1 + .../datatable/OrgUnitGroupFilterInput.jsx | 19 +++--------- .../OrgUnitGroupFilterInput.spec.jsx | 31 +++---------------- .../__tests__/useOrgUnitAncestorNames.spec.js | 7 ++++- src/hooks/useOrgUnitAncestorNames.js | 6 ++-- src/util/__tests__/orgUnits.spec.js | 9 ++++++ src/util/orgUnits.js | 4 +-- src/util/requests.js | 4 +-- 8 files changed, 33 insertions(+), 48 deletions(-) diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index eb6097cdd..846f23aca 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -628,6 +628,7 @@ const FilterInput = React.memo(function FilterInput({ layerId={layerId} filterValue={filterValue} options={options ?? []} + idToName={orgUnitIdToName} /> ) } diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx index 0641de422..84198946f 100644 --- a/src/components/datatable/OrgUnitGroupFilterInput.jsx +++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx @@ -1,12 +1,8 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useCallback, useMemo } from 'react' +import React, { useCallback } from 'react' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' -import { - ORG_UNIT_GROUPS_GRANULARITY, - SENTINEL_NO_VALUE, -} from '../../constants/dataTable.js' -import useOrgUnitAncestorNames from '../../hooks/useOrgUnitAncestorNames.js' +import { ORG_UNIT_GROUPS_GRANULARITY } from '../../constants/dataTable.js' import { isOrgUnitGroupFilter } from '../../util/filter.js' import { buildOrgUnitGroupTree, @@ -45,16 +41,8 @@ const OrgUnitGroupFilterInput = ({ layerId, filterValue, options, + idToName, }) => { - const realValues = useMemo( - () => - options - .filter(({ value }) => value !== SENTINEL_NO_VALUE) - .map((o) => o.value), - [options] - ) - const { idToName } = useOrgUnitAncestorNames(realValues) - const getMatches = useCallback( (tree, normalizedSearch) => getOrgUnitSearchMatches(tree, normalizedSearch, idToName), @@ -119,6 +107,7 @@ const OrgUnitGroupFilterInput = ({ OrgUnitGroupFilterInput.propTypes = { dataKey: PropTypes.string.isRequired, + idToName: PropTypes.instanceOf(Map).isRequired, name: PropTypes.string.isRequired, options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) .isRequired, diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx index 3b37e7cac..813687c49 100644 --- a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx +++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx @@ -12,14 +12,8 @@ import { SENTINEL_NO_VALUE, ORG_UNIT_GROUPS_GRANULARITY, } from '../../../constants/dataTable.js' -import useOrgUnitAncestorNames from '../../../hooks/useOrgUnitAncestorNames.js' import OrgUnitGroupFilterInput from '../OrgUnitGroupFilterInput.jsx' -jest.mock('../../../hooks/useOrgUnitAncestorNames.js', () => ({ - __esModule: true, - default: jest.fn(), -})) - const mockStore = configureMockStore() const ORG_UNIT_VALUES = [ @@ -40,6 +34,7 @@ const renderOrgUnitGroupFilter = (props) => { name="Org unit" layerId="layer1" options={ORG_UNIT_VALUES} + idToName={new Map()} {...props} /> @@ -55,13 +50,6 @@ const getInput = () => const openPopover = () => fireEvent.focus(getInput()) -beforeEach(() => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map(), - loading: false, - }) -}) - describe('OrgUnitGroupFilterInput - default (collapsed) tree', () => { test('shows only root nodes by default', () => { renderOrgUnitGroupFilter() @@ -107,11 +95,9 @@ describe('OrgUnitGroupFilterInput - label resolution', () => { }) test('shows the resolved name once idToName has it', () => { - useOrgUnitAncestorNames.mockReturnValue({ + renderOrgUnitGroupFilter({ idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, }) - renderOrgUnitGroupFilter() openPopover() expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument() expect(screen.queryByLabelText('country1')).not.toBeInTheDocument() @@ -251,11 +237,9 @@ describe('OrgUnitGroupFilterInput - search', () => { }) test('also narrows by resolved name, not just raw id', () => { - useOrgUnitAncestorNames.mockReturnValue({ + renderOrgUnitGroupFilter({ idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, }) - renderOrgUnitGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: 'Sierra' } }) expect(screen.getByLabelText('Sierra Leone')).toBeInTheDocument() @@ -280,11 +264,9 @@ describe('OrgUnitGroupFilterInput - search', () => { }) test('committing a name-matched custom filter dispatches the matched nodes’ prefixes, not a raw substring match against the id path', () => { - useOrgUnitAncestorNames.mockReturnValue({ + const { store } = renderOrgUnitGroupFilter({ idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, }) - const { store } = renderOrgUnitGroupFilter() openPopover() fireEvent.change(getInput(), { target: { value: 'Sierra' } }) expect(store.getActions()).toContainEqual({ @@ -301,11 +283,8 @@ describe('OrgUnitGroupFilterInput - search', () => { }) 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', () => { - useOrgUnitAncestorNames.mockReturnValue({ - idToName: new Map([['country1', 'Sierra Leone']]), - loading: false, - }) renderOrgUnitGroupFilter({ + idToName: new Map([['country1', 'Sierra Leone']]), filterValue: { granularity: ORG_UNIT_GROUPS_GRANULARITY, prefixes: ['/country1'], diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js index ca5dba33f..04bbdc1f9 100644 --- a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -7,6 +7,10 @@ jest.mock('@dhis2/app-runtime', () => ({ })) const mockEngine = {} +jest.mock('../../components/cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ nameProperty: 'displayShortName' }), +})) + jest.mock('../../util/orgUnits.js', () => ({ fetchOrgUnitPathDetails: jest.fn(), })) @@ -40,7 +44,8 @@ describe('useOrgUnitAncestorNames', () => { 'facility1', 'region2', 'facility2', - ]) + ]), + 'displayShortName' ) }) diff --git a/src/hooks/useOrgUnitAncestorNames.js b/src/hooks/useOrgUnitAncestorNames.js index 32d35e3d4..87761d78b 100644 --- a/src/hooks/useOrgUnitAncestorNames.js +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -1,9 +1,11 @@ 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 useOrgUnitAncestorNames = (distinctPathValues) => { const engine = useDataEngine() + const { nameProperty } = useCachedData() const ids = useMemo( () => [ ...new Set( @@ -25,7 +27,7 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { } let cancelled = false setLoading(true) - fetchOrgUnitPathDetails(engine, ids).then((details) => { + fetchOrgUnitPathDetails(engine, ids, nameProperty).then((details) => { if (cancelled) { return } @@ -40,7 +42,7 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { // 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]) + }, [engine, idsKey, nameProperty]) return { idToName, loading } } diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 0427f2729..28cb1fc6a 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -137,6 +137,15 @@ describe('fetchOrgUnitDetails / fetchOrgUnitPaths error handling', () => { 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', () => { diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index e512a8bad..61235952f 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -358,10 +358,10 @@ export const fetchOrgUnitPaths = async (engine, ids) => { return results.flatMap((r) => r.organisationUnits.organisationUnits ?? []) } -export const fetchOrgUnitPathDetails = async (engine, ids) => { +export const fetchOrgUnitPathDetails = async (engine, ids, nameProperty) => { const results = await fetchInBatches(engine, ids, { query: ORG_UNIT_PATH_DETAILS_QUERY, - buildVariables: (batch) => ({ ids: batch }), + buildVariables: (batch) => ({ ids: batch, nameProperty }), }) return results.reduce((acc, result) => { result.orgUnits.organisationUnits?.forEach((ou) => { diff --git a/src/util/requests.js b/src/util/requests.js index 0006ed0bf..b69382777 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -192,9 +192,9 @@ 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,displayName~rename(name),level', + fields: `id,${nameProperty}~rename(name),level`, paging: false, }), }, From f4d768f07fc2e71125e5b829a5ff1fdbf7988761 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Jul 2026 21:02:00 +0200 Subject: [PATCH 14/17] fix: populate org unit hierarchy columns for rows without coordinates --- src/loaders/__tests__/eventLoader.spec.js | 20 ++++++++++++ src/loaders/earthEngineLoader.js | 8 ++++- src/loaders/eventLoader.js | 20 ++++++++---- src/loaders/facilityLoader.js | 8 ++++- src/loaders/orgUnitLoader.js | 16 +++------ src/loaders/thematicLoader.js | 40 ++++++++++++----------- src/util/__tests__/orgUnits.spec.js | 11 +------ src/util/orgUnits.js | 17 ++-------- src/util/requests.js | 11 ------- 9 files changed, 77 insertions(+), 74 deletions(-) diff --git a/src/loaders/__tests__/eventLoader.spec.js b/src/loaders/__tests__/eventLoader.spec.js index 8ddca662b..976864bef 100644 --- a/src/loaders/__tests__/eventLoader.spec.js +++ b/src/loaders/__tests__/eventLoader.spec.js @@ -786,6 +786,26 @@ describe('attachOrgUnitPaths', () => { 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', () => { diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 19ee62010..2dd412654 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 f2722f0ee..2a96c1bbf 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -48,14 +48,20 @@ const getEventOuId = (feature) => feature.properties?.ou ?? feature.properties?.['Organisation unit'] export const attachOrgUnitPaths = async ({ config, engine }) => { - if (!config.data?.length) { - return + if (config.data?.length) { + config.data = await attachOrgUnitPathsUtil( + config.data, + engine, + getEventOuId + ) + } + if (config.dataWithoutCoords?.length) { + config.dataWithoutCoords = await attachOrgUnitPathsUtil( + config.dataWithoutCoords, + engine, + getEventOuId + ) } - config.data = await attachOrgUnitPathsUtil( - config.data, - engine, - getEventOuId - ) } // Expands USER_ORGUNIT/_CHILDREN/_GRANDCHILDREN into ids; [id] if literal. diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index ef8e07ed7..7c1c825b2 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -12,6 +12,8 @@ import { getPolygonItems, getStyledOrgUnits, getCoordinateField, + attachOrgUnitPaths, + getMissingOrgUnitId, getOrgUnitsWithoutCoordsCount, addGroupCountsToLegend, loadGroupSetData, @@ -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 1df10a152..5b130aa9a 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, @@ -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 fbe8feac8..b7410f212 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/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 28cb1fc6a..e916a0c0f 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -11,7 +11,6 @@ import { fetchAndParseGroupSet, loadGroupSetData, getUserOrgUnitIdsByKeyword, - fetchOrgUnitDetails, fetchOrgUnitPaths, fetchOrgUnitPathDetails, attachOrgUnitPaths, @@ -63,15 +62,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')), diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 61235952f..7da675f95 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -27,7 +27,6 @@ import { GEOFEATURES_QUERY, ORG_UNITS_COUNT_QUERY, ORG_UNITS_PATHS_QUERY, - ORG_UNIT_DETAILS_QUERY, ORG_UNIT_PATH_DETAILS_QUERY, } from './requests.js' @@ -277,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, @@ -337,19 +339,6 @@ const fetchInBatches = async (engine, ids, { query, buildVariables }) => { .map((result) => result.value) } -export const fetchOrgUnitDetails = async (engine, ids) => { - const results = await fetchInBatches(engine, ids, { - query: ORG_UNIT_DETAILS_QUERY, - buildVariables: (batch) => ({ ids: batch }), - }) - return results.reduce((acc, result) => { - result.orgUnits.organisationUnits?.forEach((ou) => { - acc[ou.id] = { level: ou.level, parentName: ou.parent?.name } - }) - return acc - }, {}) -} - export const fetchOrgUnitPaths = async (engine, ids) => { const results = await fetchInBatches(engine, ids, { query: ORG_UNITS_PATHS_QUERY, diff --git a/src/util/requests.js b/src/util/requests.js index b69382777..19d751863 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -178,17 +178,6 @@ export const ORG_UNITS_COUNT_QUERY = { }, } -export const ORG_UNIT_DETAILS_QUERY = { - orgUnits: { - resource: 'organisationUnits', - params: ({ ids }) => ({ - filter: `id:in:[${ids.join(',')}]`, - fields: 'id,level,parent[displayName~rename(name)]', - paging: false, - }), - }, -} - export const ORG_UNIT_PATH_DETAILS_QUERY = { orgUnits: { resource: 'organisationUnits', From 78abcd621f20173e31bfab9781b4fccdcff1f281 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Jul 2026 21:35:16 +0200 Subject: [PATCH 15/17] chore: add tests --- src/loaders/__tests__/facilityLoader.spec.js | 126 +++++++++++++++++++ src/loaders/__tests__/orgUnitLoader.spec.js | 125 ++++++++++++++++++ src/loaders/facilityLoader.js | 2 +- src/loaders/orgUnitLoader.js | 2 +- 4 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 src/loaders/__tests__/facilityLoader.spec.js create mode 100644 src/loaders/__tests__/orgUnitLoader.spec.js diff --git a/src/loaders/__tests__/facilityLoader.spec.js b/src/loaders/__tests__/facilityLoader.spec.js new file mode 100644 index 000000000..b701a9c7f --- /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 000000000..a2082f58b --- /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/facilityLoader.js b/src/loaders/facilityLoader.js index 7c1c825b2..b8b66c49b 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -21,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 } ) => { diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 5b130aa9a..9d32ea932 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -23,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 } ) => { From 527d9b0ccec66a8dc4845f6ccaa86d43f45e0372 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 7 Sep 2026 12:22:56 +0200 Subject: [PATCH 16/17] chore: regenerate i18n/en.pot after PR6 rebase Same drift as prior rebases: the merge driver's timestamp-only resolutions left message entries stale relative to the merged source. Re-run the extraction to bring it back in sync. --- i18n/en.pot | 59 ++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 9375e4be9..4aa2ba8ed 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" From 589879e2a173bd6593247d237300efad12d9f6c5 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 7 Sep 2026 15:19:57 +0200 Subject: [PATCH 17/17] fix: avoid re-fetching org-unit names already known from row data useOrgUnitAncestorNames flattened every ancestor id out of every org unit path and fetched a name for all of them, including a row's own org unit (already have its name) and its immediate parent (already have parentName) - for a facility layer, that's the vast majority of the distinct-id set, since self+immediate-parent dominate a typical many-leaves/few-ancestors hierarchy. Seed idToName from data already in memory via buildKnownOrgUnitNames, and only fetch what's still missing. Also add a module-level session cache so a name fetched once is never re-fetched for the rest of the session, even across unrelated table views. Safe for event/tracked-entity layers without any special-casing: attachOrgUnitPaths never sets name/parentName on those rows, and an event/entity's own id never collides with an org-unit id actually being looked up, so buildKnownOrgUnitNames naturally contributes nothing for them - confirmed by its own test coverage. --- src/components/datatable/useTableData.js | 11 +++- .../__tests__/useOrgUnitAncestorNames.spec.js | 65 +++++++++++++++++- src/hooks/useOrgUnitAncestorNames.js | 54 ++++++++++++--- src/util/__tests__/orgUnits.spec.js | 66 +++++++++++++++++++ src/util/orgUnits.js | 13 ++++ 5 files changed, 196 insertions(+), 13 deletions(-) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index c7751237f..55f62b606 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -21,6 +21,7 @@ import { } 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, @@ -254,8 +255,14 @@ export const useTableData = ({ ), [headers, columnOptions] ) - const { idToName: orgUnitIdToName } = - useOrgUnitAncestorNames(orgUnitPathValues) + const knownOrgUnitNames = useMemo( + () => buildKnownOrgUnitNames(dataWithAggregations), + [dataWithAggregations] + ) + const { idToName: orgUnitIdToName } = useOrgUnitAncestorNames( + orgUnitPathValues, + knownOrgUnitNames + ) const rows = useMemo(() => { if (errorCode.current) { diff --git a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js index 04bbdc1f9..66d542c49 100644 --- a/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js +++ b/src/hooks/__tests__/useOrgUnitAncestorNames.spec.js @@ -1,6 +1,8 @@ import { renderHook, waitFor } from '@testing-library/react' import { fetchOrgUnitPathDetails } from '../../util/orgUnits.js' -import useOrgUnitAncestorNames from '../useOrgUnitAncestorNames.js' +import useOrgUnitAncestorNames, { + __resetOrgUnitNameSessionCacheForTests, +} from '../useOrgUnitAncestorNames.js' jest.mock('@dhis2/app-runtime', () => ({ useDataEngine: () => mockEngine, @@ -17,6 +19,7 @@ jest.mock('../../util/orgUnits.js', () => ({ beforeEach(() => { fetchOrgUnitPathDetails.mockReset() + __resetOrgUnitNameSessionCacheForTests() }) describe('useOrgUnitAncestorNames', () => { @@ -63,4 +66,64 @@ describe('useOrgUnitAncestorNames', () => { }) 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 index 87761d78b..cad33a0f1 100644 --- a/src/hooks/useOrgUnitAncestorNames.js +++ b/src/hooks/useOrgUnitAncestorNames.js @@ -3,7 +3,17 @@ import { useEffect, useMemo, useState } from 'react' import { useCachedData } from '../components/cachedDataProvider/CachedDataProvider.jsx' import { fetchOrgUnitPathDetails } from '../util/orgUnits.js' -const useOrgUnitAncestorNames = (distinctPathValues) => { +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( @@ -25,24 +35,48 @@ const useOrgUnitAncestorNames = (distinctPathValues) => { 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, ids, nameProperty).then((details) => { - if (cancelled) { - return + fetchOrgUnitPathDetails(engine, idsToFetch, nameProperty).then( + (details) => { + if (cancelled) { + return + } + Object.entries(details).forEach(([id, d]) => { + orgUnitNameSessionCache.set(id, d.name) + }) + setIdToName(buildMerged()) + setLoading(false) } - setIdToName( - new Map(Object.entries(details).map(([id, d]) => [id, d.name])) - ) - 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]) + }, [engine, idsKey, nameProperty, knownIdToName]) return { idToName, loading } } diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index e916a0c0f..3673d4929 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -14,6 +14,7 @@ import { fetchOrgUnitPaths, fetchOrgUnitPathDetails, attachOrgUnitPaths, + buildKnownOrgUnitNames, } from '../orgUnits.js' describe('getUserOrgUnitIdsByKeyword', () => { @@ -193,6 +194,71 @@ describe('attachOrgUnitPaths', () => { }) }) +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', () => { it('should return styled features and legend for facility layer', () => { const features = [ diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 7da675f95..421b3e261 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -347,6 +347,19 @@ export const fetchOrgUnitPaths = async (engine, ids) => { 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,