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