({
+ isColorCell: renderer === RENDERER_COLOR,
+ isIconCell: renderer === RENDERER_ICON,
+ isDateCell: renderer === RENDERER_DATE,
+ isDateOnlyCell: type === TYPE_DATE,
+ isOrgUnitHierarchyCell: renderer === RENDERER_ORG_UNIT,
+ isOrgUnitNameCell: renderer === RENDERER_ORG_UNIT_NAME,
+ isBooleanCell: renderer === RENDERER_BOOLEAN,
+})
+
+const NO_VALUE = '—'
+
+const CellValue = ({
+ value,
+ renderer,
+ type,
+ orgUnitIdToName,
+ keyAnalysisDigitGroupSeparator,
+}) => {
+ if (value == null) {
+ return NO_VALUE
+ }
+
+ const { isIconCell } = getCellRendererFlags(renderer, type)
+
+ if (isIconCell) {
+ return (
+
{
+ e.target.style.visibility = 'hidden'
+ }}
+ />
+ )
+ }
+
+ return formatCellText(value, {
+ renderer,
+ type,
+ orgUnitIdToName,
+ keyAnalysisDigitGroupSeparator,
+ })
+}
+
+CellValue.propTypes = {
+ keyAnalysisDigitGroupSeparator: PropTypes.string,
+ orgUnitIdToName: PropTypes.instanceOf(Map),
+ renderer: PropTypes.string,
+ type: PropTypes.string,
+ value: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ PropTypes.bool,
+ ]),
+}
+
+export default CellValue
diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx
index 1e10612949..b259ee6edc 100644
--- a/src/components/datatable/DataTable.jsx
+++ b/src/components/datatable/DataTable.jsx
@@ -1,43 +1,37 @@
import i18n from '@dhis2/d2-i18n'
import {
DataTableRow,
- DataTableColumnHeader,
+ DataTableCell,
ComponentCover,
CenteredContent,
CircularLoader,
- IconSync16,
} from '@dhis2/ui'
import cx from 'classnames'
import PropTypes from 'prop-types'
-import React, {
- useReducer,
- useCallback,
- useMemo,
- useEffect,
- useRef,
- useState,
-} from 'react'
+import React, { useCallback, useMemo, useEffect, useRef, useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { TableVirtuoso } from 'react-virtuoso'
+import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js'
import { setSelectionFilter } from '../../actions/dataTable.js'
import { highlightFeature } from '../../actions/feature.js'
import { editLayer, setForceClientCluster } from '../../actions/layers.js'
import {
toggleFeatureSelection,
selectFeatureRange,
+ selectAllFeatures,
+ clearSelection,
} from '../../actions/selection.js'
import {
SENTINEL_SELECTED_ROW,
- SORT_ASCENDING,
+ ORG_UNIT_ID_DATA_KEY,
} from '../../constants/dataTable.js'
+import { isDarkColor } from '../../util/colors.js'
import {
buildFeatureIndex,
- getNextSorting,
- getRowClickAction,
+ getLayerSelectedIds,
getRowId,
hasActiveDataTableFilters,
isFilterable,
- shouldClearFeatureHighlight,
} from '../../util/dataTable.js'
import {
getPinnedCellProps,
@@ -46,22 +40,29 @@ import {
getVisibleHeaders,
} from '../../util/tableColumns.js'
import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx'
-import { SortIcon } from '../core/icons.jsx'
+import CellValue, { getCellRendererFlags } from './CellValue.jsx'
import FilterInput from './FilterInput.jsx'
-import RowCells from './RowCells.jsx'
+import {
+ SelectionCheckboxHeaderCell,
+ SelectionCheckboxCell,
+} from './SelectionCheckboxColumn.jsx'
import SelectionFilterButton from './SelectionFilterButton.jsx'
+import SortableColumnHeader from './SortableColumnHeader.jsx'
import styles from './styles/DataTable.module.css'
import TableContextMenu from './TableContextMenu.jsx'
import TableComponents from './TableVirtuosoComponents.jsx'
-import TopTooltip from './TopTooltip.jsx'
import { useColumnWidths } from './useColumnWidths.js'
+import { useRowClickSelection } from './useRowClickSelection.js'
+import { useRowContextMenuHighlight } from './useRowContextMenuHighlight.js'
import { useRowSelection } from './useRowSelection.js'
+import { useSortState } from './useSortState.js'
import { useTableData } from './useTableData.js'
const TABLE_STYLE = { height: '100%', width: '100%' }
const VIEWPORT_OVERSCAN = { top: 400, bottom: 400 }
const Table = ({
+ activeLayerId,
availableWidth,
onCountChange,
onHeadersChange,
@@ -74,7 +75,6 @@ const Table = ({
const virtuosoRef = useRef(null)
const { mapViews } = useSelector((state) => state.map)
- const activeLayerId = useSelector((state) => state.dataTable)
const dispatch = useDispatch()
const feature = useSelector((state) => state.feature)
@@ -84,23 +84,10 @@ const Table = ({
)
const mapBounds = useSelector((state) => state.ui.mapBounds)
const selectionFilter = useSelector((state) => state.ui.selectionFilter)
- const [{ sortField, sortDirection }, setSorting] = useReducer(
- (sorting, newSorting) => ({ ...sorting, ...newSorting }),
- {
- sortField: 'name',
- sortDirection: SORT_ASCENDING,
- }
- )
+ const { sortField, sortDirection, sortData } = useSortState('name')
const layer = mapViews.find((l) => l.id === activeLayerId)
- const sortData = useCallback(
- ({ name }) => {
- setSorting(getNextSorting(name, { sortField, sortDirection }))
- },
- [sortField, sortDirection]
- )
-
// Read via ref rather than a dependency, so this callback stays stable
// across hovers instead of getting a new identity on every single mouse-enter
const featureRef = useRef(feature)
@@ -127,14 +114,15 @@ const Table = ({
},
[dispatch, layer.id]
)
- const clearFeatureHighlight = useCallback(
- (event) => {
- if (shouldClearFeatureHighlight(event)) {
- dispatch(highlightFeature(null))
- }
- },
+ const onClearHighlight = useCallback(
+ () => dispatch(highlightFeature(null)),
[dispatch]
)
+ const { onContextMenuOpen, guardedClear, onMenuClose } =
+ useRowContextMenuHighlight({
+ onPin: setFeatureHighlight,
+ onClear: onClearHighlight,
+ })
const featureById = useMemo(
() => buildFeatureIndex(layer.data),
@@ -146,6 +134,7 @@ const Table = ({
const onRowContextMenu = useCallback(
(e, row) => {
e.preventDefault()
+ onContextMenuOpen(row)
const id = getRowId(row)
const feature = featureById.get(id)
setTableContextMenu({
@@ -154,11 +143,11 @@ const Table = ({
featureProps: feature?.properties ?? { id },
})
},
- [featureById]
+ [featureById, onContextMenuOpen]
)
const selectedIds = useMemo(
- () => (selection.layerId === layer.id ? selection.ids : []),
+ () => getLayerSelectedIds(selection, layer.id),
[selection, layer.id]
)
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
@@ -233,37 +222,20 @@ const Table = ({
onCountChange?.(totalCount, filteredCount)
}, [onCountChange, totalCount, filteredCount])
- const lastClickedRowIndexRef = useRef(null)
-
- const onRowClick = useCallback(
- (row, event) => {
- const id = getRowId(row)
-
- if (!id || !rows) {
- return
- }
-
- const rowIndex = rows.findIndex((r) => getRowId(r) === id)
- const action = getRowClickAction(event, {
- id,
- rowIndex,
- rows,
- lastClickedRowIndex: lastClickedRowIndexRef.current,
- })
-
- if (!action) {
- return
- }
-
- if (action.type === 'range') {
- dispatch(selectFeatureRange(action.ids, layer.id))
- } else {
- dispatch(toggleFeatureSelection(action.id, layer.id))
- }
- lastClickedRowIndexRef.current = rowIndex
- },
- [dispatch, layer.id, rows]
+ const onToggleRow = useCallback(
+ (id) => dispatch(toggleFeatureSelection(id, layer.id)),
+ [dispatch, layer.id]
+ )
+ const onSelectRowRange = useCallback(
+ (ids) => dispatch(selectFeatureRange(ids, layer.id)),
+ [dispatch, layer.id]
)
+ const { onRowClick, onCheckboxToggle, resetAnchor } = useRowClickSelection({
+ rows,
+ onToggle: onToggleRow,
+ onSelectRange: onSelectRowRange,
+ selectedIdSet,
+ })
const onRowDoubleClick = useCallback(
(row) => {
@@ -303,7 +275,7 @@ const Table = ({
const tableContext = useMemo(
() => ({
onMouseEnter: setFeatureHighlight,
- onMouseLeave: clearFeatureHighlight,
+ onMouseLeave: guardedClear,
onContextMenu: onRowContextMenu,
onRowClick,
onRowDoubleClick,
@@ -316,7 +288,7 @@ const Table = ({
}),
[
setFeatureHighlight,
- clearFeatureHighlight,
+ guardedClear,
onRowContextMenu,
onRowClick,
onRowDoubleClick,
@@ -359,13 +331,37 @@ const Table = ({
[rows]
)
- const { isAllSelected, onToggleSelectAll, onReverseSelection } =
- useRowSelection({
- selectedIds,
- selectedIdSet,
- allRowIds,
- layerId: layer.id,
- })
+ const onSelectionChange = useCallback(
+ (nextIds) => {
+ if (nextIds.length) {
+ dispatch(selectAllFeatures(nextIds, layer.id))
+ } else {
+ dispatch(clearSelection())
+ }
+ },
+ [dispatch, layer.id]
+ )
+
+ const {
+ isAllSelected,
+ onToggleSelectAll: onToggleSelectAllRows,
+ onReverseSelection: onReverseSelectionRows,
+ } = useRowSelection({
+ selectedIds,
+ selectedIdSet,
+ allRowIds,
+ onChange: onSelectionChange,
+ })
+
+ const onToggleSelectAll = useCallback(() => {
+ resetAnchor()
+ onToggleSelectAllRows()
+ }, [resetAnchor, onToggleSelectAllRows])
+
+ const onReverseSelection = useCallback(() => {
+ resetAnchor()
+ onReverseSelectionRows()
+ }, [resetAnchor, onReverseSelectionRows])
const computeItemKey = useCallback(
(index, row) => getRowId(row) ?? index,
@@ -375,11 +371,18 @@ const Table = ({
const fixedHeaderContent = useCallback(
() => (
-
+ sortData({ name: SENTINEL_SELECTED_ROW })
+ }
onFilterIconClick={Function.prototype}
showFilter={true}
filter={
@@ -390,53 +393,7 @@ const Table = ({
}
/>
}
- >
-
-
-
-
-
-
-
-
-
-
-
-
+ />
{visibleHeaders.map(
({ name, dataKey, type, optionSet, renderer }, index) => {
const { fixed, left, isLastPinned } =
@@ -446,11 +403,17 @@ const Table = ({
columnWidths,
})
return (
-
+ dispatch(
+ setDataFilter(
+ activeLayerId,
+ dataKey,
+ value
+ )
+ )
+ }
+ onClear={() =>
+ dispatch(
+ clearDataFilter(
+ activeLayerId,
+ dataKey
+ )
+ )
+ }
/>
)
}
@@ -477,43 +467,14 @@ const Table = ({
? `${columnWidths[index]}px`
: 'auto'
}
- >
-
-
- {name}
-
-
-
-
-
-
+ />
)
}
)}
),
[
+ activeLayerId,
isCheckboxColumnPinned,
selectionFilter,
dispatch,
@@ -524,6 +485,8 @@ const Table = ({
sortDirection,
visibleHeaders,
pinnedLeftOffsets,
+ layer.dataFilters,
+ layer.optionSetOptionsByCode,
pinnedColumnCount,
columnWidths,
columnOptions,
@@ -534,47 +497,6 @@ const Table = ({
]
)
- const onToggleSelection = useCallback(
- (rowId) => dispatch(toggleFeatureSelection(rowId, layer.id)),
- [dispatch, layer.id]
- )
-
- const itemContent = useCallback(
- (_, row) => (
-
- ),
- [
- visibleHeaders,
- selectedIdSet,
- feature,
- layer.id,
- isCheckboxColumnPinned,
- pinnedLeftOffsets,
- pinnedColumnCount,
- columnWidths,
- rendererByDataKey,
- typeByDataKey,
- keyAnalysisDigitGroupSeparator,
- orgUnitIdToName,
- onToggleSelection,
- ]
- )
-
if (error) {
return (
@@ -601,7 +523,95 @@ const Table = ({
computeItemKey={computeItemKey}
increaseViewportBy={VIEWPORT_OVERSCAN}
fixedHeaderContent={fixedHeaderContent}
- itemContent={itemContent}
+ itemContent={(_, row) => {
+ const rowId = getRowId(row)
+ const isSelected = !!rowId && selectedIdSet.has(rowId)
+ const isHovered =
+ !!rowId &&
+ feature?.id === rowId &&
+ feature?.layerId === layer.id
+
+ const cellsByDataKey = new Map(
+ row.map((cell) => [cell.dataKey, cell])
+ )
+
+ return (
+ <>
+
+ rowId && onCheckboxToggle(rowId, e)
+ }
+ />
+ {visibleHeaders.map(({ dataKey }, index) => {
+ const cell = cellsByDataKey.get(dataKey)
+ if (!cell) {
+ return null
+ }
+ const { value, align } = cell
+ const { fixed, left, width, isLastPinned } =
+ getPinnedCellProps(dataKey, index, {
+ pinnedLeftOffsets,
+ pinnedColumnCount,
+ columnWidths,
+ })
+ const renderer = rendererByDataKey.get(dataKey)
+ const type = typeByDataKey.get(dataKey)
+ const { isColorCell } = getCellRendererFlags(
+ renderer,
+ type
+ )
+ return (
+
+
+
+ )
+ })}
+ >
+ )
+ }}
/>
{(isLoading || layer?.isLoaded === false || layer?.isLoading) && (
@@ -622,13 +632,17 @@ const Table = ({
layer={layer}
selectedIds={selectedIds}
filteredIds={hasActiveFilters ? allRowIds : null}
- onClose={() => setTableContextMenu(null)}
+ onClose={(highlightChanged) => {
+ setTableContextMenu(null)
+ onMenuClose(highlightChanged)
+ }}
/>
>
)
}
Table.propTypes = {
+ activeLayerId: PropTypes.string,
availableWidth: PropTypes.number,
globalSearch: PropTypes.string,
onClearFilters: PropTypes.func,
diff --git a/src/components/datatable/DataTableButton.jsx b/src/components/datatable/DataTableButton.jsx
new file mode 100644
index 0000000000..39232a5c88
--- /dev/null
+++ b/src/components/datatable/DataTableButton.jsx
@@ -0,0 +1,47 @@
+import i18n from '@dhis2/d2-i18n'
+import React from 'react'
+import { useDispatch, useSelector } from 'react-redux'
+import {
+ closeDataTable,
+ openDataTable,
+ toggleDataTable,
+} from '../../actions/dataTable.js'
+import {
+ getEligibleDataTableLayers,
+ isDataTableOpen,
+} from '../../util/dataTable.js'
+import styles from './styles/DataTableButton.module.css'
+
+const DataTableButton = () => {
+ const dispatch = useDispatch()
+ const dataTable = useSelector((state) => state.dataTable)
+ const mapViews = useSelector((state) => state.map.mapViews)
+ const eligibleLayers = getEligibleDataTableLayers(mapViews)
+
+ const onClick = () => {
+ if (isDataTableOpen(dataTable)) {
+ dispatch(closeDataTable())
+ return
+ }
+ if (dataTable.openIds.length > 0) {
+ dispatch(openDataTable())
+ return
+ }
+ if (eligibleLayers.length >= 1) {
+ dispatch(toggleDataTable(eligibleLayers[0].id))
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default DataTableButton
diff --git a/src/components/datatable/DateGroupFilterInput.jsx b/src/components/datatable/DateGroupFilterInput.jsx
index 3c7131c764..dda9c29aba 100644
--- a/src/components/datatable/DateGroupFilterInput.jsx
+++ b/src/components/datatable/DateGroupFilterInput.jsx
@@ -1,7 +1,6 @@
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,
@@ -29,13 +28,12 @@ const parseFilterValue = (filterValue) => ({
const sanitizeInput = (value) => value.replace(DATE_INPUT_DISALLOWED, '')
-const commitSearch = (text, { dispatch, layerId, dataKey }) =>
- dispatch(setDataFilter(layerId, dataKey, text))
+const commitSearch = (text, { onChange }) => onChange(text)
const DateGroupFilterInput = ({
- dataKey,
name,
- layerId,
+ onChange,
+ onClear,
filterValue,
options,
type,
@@ -46,8 +44,8 @@ const DateGroupFilterInput = ({
)
const groupFilter = useGroupFilterInput({
- dataKey,
- layerId,
+ onChange,
+ onClear,
filterValue,
options,
granularity: DATE_GROUPS_GRANULARITY,
@@ -70,17 +68,17 @@ const DateGroupFilterInput = ({
}
DateGroupFilterInput.propTypes = {
- dataKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string }))
.isRequired,
type: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
filterValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
PropTypes.object,
]),
- layerId: PropTypes.string,
}
export default DateGroupFilterInput
diff --git a/src/components/datatable/FilterDropdownPopover.jsx b/src/components/datatable/FilterDropdownPopover.jsx
index ae2f257c0c..676b95cddc 100644
--- a/src/components/datatable/FilterDropdownPopover.jsx
+++ b/src/components/datatable/FilterDropdownPopover.jsx
@@ -1,6 +1,7 @@
import { Layer, Popper } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React from 'react'
+import useKeyDown from '../../hooks/useKeyDown.js'
const ESTIMATED_POPOVER_HEIGHT = 340 // Rough popover height used to flip the dropdown when there isn't room to open downward
@@ -28,18 +29,22 @@ export const FilterDropdownPopover = ({
onClickOutside,
className,
children,
-}) => (
-
-
- {children}
-
-
-)
+}) => {
+ useKeyDown('Escape', onClickOutside)
+
+ return (
+
+
+ {children}
+
+
+ )
+}
FilterDropdownPopover.propTypes = {
children: PropTypes.node.isRequired,
diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx
index 846f23acae..a05337e8e3 100644
--- a/src/components/datatable/FilterInput.jsx
+++ b/src/components/datatable/FilterInput.jsx
@@ -3,9 +3,7 @@ import { Input, IconFilter16, IconSync16 } from '@dhis2/ui'
import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useCallback, useMemo, useRef, useState } from 'react'
-import { useDispatch, useSelector } from 'react-redux'
import { Virtuoso } from 'react-virtuoso'
-import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js'
import {
SENTINEL_ANY_VALUE,
SENTINEL_NO_VALUE,
@@ -80,15 +78,15 @@ const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g
const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
dataKey,
name,
- layerId,
filterValue,
options,
resolveLabel,
type,
renderer,
allowCustomFilter = true,
+ onChange,
+ onClear,
}) {
- const dispatch = useDispatch()
const anchorRef = useRef(null)
const listRef = useRef(null)
const [isOpen, setIsOpen] = useState(false)
@@ -109,10 +107,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
const { dropdownPlacement, dropdownSide, tooltipPlacement } =
getDropdownPlacement(anchorRect)
- const applyValues = (next) =>
- next.length
- ? dispatch(setDataFilter(layerId, dataKey, next))
- : dispatch(clearDataFilter(layerId, dataKey))
+ const applyValues = (next) => (next.length ? onChange(next) : onClear())
const toggleValue = (value) => {
const next = selected.includes(value)
@@ -126,7 +121,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
const applyCustomFilter = (text) => {
if (!text) {
- dispatch(clearDataFilter(layerId, dataKey))
+ onClear()
return
}
if (isOrgUnitRenderer) {
@@ -134,16 +129,14 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
const values = realValues.filter((value) =>
resolveLabel(value).toLowerCase().includes(lower)
)
- dispatch(
- setDataFilter(layerId, dataKey, {
- values,
- searchDerived: true,
- searchText: text,
- })
- )
+ onChange({
+ values,
+ searchDerived: true,
+ searchText: text,
+ })
return
}
- dispatch(setDataFilter(layerId, dataKey, text))
+ onChange(text)
}
const isIconColumn = renderer === RENDERER_ICON
@@ -234,7 +227,7 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
const trimmed = sanitized.trim()
if (trimmed === '') {
if (hasActiveFilter) {
- dispatch(clearDataFilter(layerId, dataKey))
+ onClear()
}
return
}
@@ -296,10 +289,6 @@ const SearchableFilterPopover = React.memo(function SearchableFilterPopover({
onEnterKey()
closePopover()
break
- case 'Escape':
- event.preventDefault()
- closePopover()
- break
default:
break
}
@@ -505,12 +494,13 @@ SearchableFilterPopover.propTypes = {
.isRequired,
resolveLabel: PropTypes.func.isRequired,
type: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
allowCustomFilter: PropTypes.bool,
filterValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
- layerId: PropTypes.string,
renderer: PropTypes.string,
}
@@ -553,18 +543,27 @@ PlainSearchableFilter.propTypes = {
type: PropTypes.string,
}
-const OptionSetSearchableFilter = ({ optionSetId, ...props }) => {
- const { optionSet } = useOptionSet(optionSetId)
+const OptionSetSearchableFilter = ({
+ optionSetId,
+ resolvedOptionNames,
+ ...props
+}) => {
+ const { optionSet } = useOptionSet(
+ resolvedOptionNames ? undefined : optionSetId
+ )
const optionByCode = useMemo(() => {
+ if (resolvedOptionNames) {
+ return new Map(Object.entries(resolvedOptionNames))
+ }
const map = new Map()
- optionSet?.options.forEach((o) => map.set(o.code, o))
+ optionSet?.options.forEach((o) => map.set(o.code, o.name))
return map
- }, [optionSet])
+ }, [resolvedOptionNames, optionSet])
const resolveLabel = useCallback(
(value) =>
value === SENTINEL_NO_VALUE
? i18n.t('No value')
- : optionByCode.get(value)?.name ?? value,
+ : optionByCode.get(value) ?? value,
[optionByCode]
)
return (
@@ -578,6 +577,7 @@ const OptionSetSearchableFilter = ({ optionSetId, ...props }) => {
OptionSetSearchableFilter.propTypes = {
optionSetId: PropTypes.string.isRequired,
+ resolvedOptionNames: PropTypes.object,
}
const FilterInput = React.memo(function FilterInput({
@@ -586,33 +586,22 @@ const FilterInput = React.memo(function FilterInput({
name,
options,
optionSetId,
+ resolvedOptionNames,
renderer,
orgUnitIdToName,
+ filterValue,
+ onChange,
+ onClear,
}) {
- const dataTable = useSelector((state) => state.dataTable)
- const map = useSelector((state) => state.map)
-
- const overlay =
- dataTable && map.mapViews.find((layer) => layer.id === dataTable)
-
- let layerId
- let filters
- if (overlay) {
- layerId = overlay.id
- filters = overlay.dataFilters || {}
- }
-
- const filterValue = filters?.[dataKey]
-
const isDateType =
type === TYPE_DATE || type === TYPE_DATETIME || type === TYPE_TIME
if (isDateType) {
return (
)
}
@@ -652,12 +643,13 @@ const FilterInput = React.memo(function FilterInput({
)
})
@@ -666,10 +658,18 @@ FilterInput.propTypes = {
dataKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onClear: PropTypes.func.isRequired,
+ filterValue: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.arrayOf(PropTypes.string),
+ PropTypes.object,
+ ]),
optionSetId: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })),
orgUnitIdToName: PropTypes.instanceOf(Map),
renderer: PropTypes.string,
+ resolvedOptionNames: PropTypes.object,
}
export default FilterInput
diff --git a/src/components/datatable/OrgUnitGroupFilterInput.jsx b/src/components/datatable/OrgUnitGroupFilterInput.jsx
index 84198946f9..b658831854 100644
--- a/src/components/datatable/OrgUnitGroupFilterInput.jsx
+++ b/src/components/datatable/OrgUnitGroupFilterInput.jsx
@@ -1,7 +1,6 @@
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 {
@@ -36,9 +35,9 @@ const parseFilterValue = (filterValue) => ({
})
const OrgUnitGroupFilterInput = ({
- dataKey,
name,
- layerId,
+ onChange,
+ onClear,
filterValue,
options,
idToName,
@@ -50,10 +49,7 @@ const OrgUnitGroupFilterInput = ({
)
const commitSearch = useCallback(
- (
- text,
- { tree, dispatch, layerId: layerIdArg, dataKey: dataKeyArg }
- ) => {
+ (text, { tree, onChange: onChangeArg }) => {
const matches = getOrgUnitSearchMatches(
tree,
text.toLowerCase(),
@@ -66,25 +62,19 @@ const OrgUnitGroupFilterInput = ({
.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,
- })
- )
+ onChangeArg({
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: matchedPrefixes,
+ searchDerived: true,
+ searchText: text,
+ })
},
[idToName]
)
const groupFilter = useGroupFilterInput({
- dataKey,
- layerId,
+ onChange,
+ onClear,
filterValue,
options,
granularity: ORG_UNIT_GROUPS_GRANULARITY,
@@ -106,17 +96,17 @@ const OrgUnitGroupFilterInput = ({
}
OrgUnitGroupFilterInput.propTypes = {
- dataKey: PropTypes.string.isRequired,
idToName: PropTypes.instanceOf(Map).isRequired,
name: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string }))
.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onClear: PropTypes.func.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
deleted file mode 100644
index b0ef0b2fb4..0000000000
--- a/src/components/datatable/RowCells.jsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import { DataTableCell } from '@dhis2/ui'
-import cx from 'classnames'
-import PropTypes from 'prop-types'
-import React from 'react'
-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 {
- 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'
-
-const RowCells = ({
- row,
- visibleHeaders,
- selectedIdSet,
- hoveredFeature,
- layerId,
- isCheckboxColumnPinned,
- pinnedLeftOffsets,
- pinnedColumnCount,
- columnWidths,
- rendererByDataKey,
- typeByDataKey,
- keyAnalysisDigitGroupSeparator,
- orgUnitIdToName,
- onToggleSelection,
-}) => {
- const rowId = getRowId(row)
- const isSelected = !!rowId && selectedIdSet.has(rowId)
- const isHovered =
- !!rowId &&
- hoveredFeature?.id === rowId &&
- hoveredFeature?.layerId === layerId
-
- const cellsByDataKey = new Map(row.map((cell) => [cell.dataKey, cell]))
-
- return (
- <>
-
- rowId && onToggleSelection(rowId)}
- onClick={(e) => e.stopPropagation()}
- />
-
- {visibleHeaders.map(({ dataKey }, index) => {
- const cell = cellsByDataKey.get(dataKey)
- if (!cell) {
- return null
- }
- const { value, align } = cell
- const { fixed, left, width, isLastPinned } = getPinnedCellProps(
- dataKey,
- index,
- {
- pinnedLeftOffsets,
- pinnedColumnCount,
- columnWidths,
- }
- )
- const renderer = rendererByDataKey.get(dataKey)
- const isColorCell = renderer === RENDERER_COLOR
- 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 (
-
- {isColorCell && value?.toLowerCase()}
- {isIconCell && value && (
-
{
- e.target.style.visibility = 'hidden'
- }}
- />
- )}
- {isDateCell &&
- value &&
- (isDateOnlyCell
- ? formatDate(value)
- : formatDatetime(value))}
- {isOrgUnitHierarchyCell &&
- value &&
- formatOrgUnitPathBreadcrumb(value, orgUnitIdToName)}
- {isOrgUnitNameCell &&
- value &&
- formatOrgUnitOwnName(value, orgUnitIdToName)}
- {isBooleanCell && value != null && formatBoolean(value)}
- {!isColorCell &&
- !isIconCell &&
- !isDateCell &&
- !isOrgUnitHierarchyCell &&
- !isOrgUnitNameCell &&
- !isBooleanCell &&
- formatWithSeparator(
- value,
- keyAnalysisDigitGroupSeparator
- )}
-
- )
- })}
- >
- )
-}
-
-RowCells.propTypes = {
- columnWidths: PropTypes.array.isRequired,
- isCheckboxColumnPinned: PropTypes.bool.isRequired,
- pinnedColumnCount: PropTypes.number.isRequired,
- pinnedLeftOffsets: PropTypes.object.isRequired,
- rendererByDataKey: PropTypes.instanceOf(Map).isRequired,
- row: PropTypes.array.isRequired,
- selectedIdSet: PropTypes.instanceOf(Set).isRequired,
- typeByDataKey: PropTypes.instanceOf(Map).isRequired,
- visibleHeaders: PropTypes.array.isRequired,
- onToggleSelection: PropTypes.func.isRequired,
- hoveredFeature: PropTypes.object,
- keyAnalysisDigitGroupSeparator: PropTypes.string,
- layerId: PropTypes.string,
- orgUnitIdToName: PropTypes.object,
-}
-
-export default RowCells
diff --git a/src/components/datatable/SelectionCheckboxColumn.jsx b/src/components/datatable/SelectionCheckboxColumn.jsx
new file mode 100644
index 0000000000..b1ee0a1ea1
--- /dev/null
+++ b/src/components/datatable/SelectionCheckboxColumn.jsx
@@ -0,0 +1,131 @@
+import i18n from '@dhis2/d2-i18n'
+import { DataTableColumnHeader, DataTableCell, IconSync16 } from '@dhis2/ui'
+import cx from 'classnames'
+import PropTypes from 'prop-types'
+import React from 'react'
+import { SENTINEL_SELECTED_ROW } from '../../constants/dataTable.js'
+import { SortIcon } from '../core/icons.jsx'
+import styles from './styles/DataTable.module.css'
+import TopTooltip from './TopTooltip.jsx'
+
+export const SelectionCheckboxHeaderCell = ({
+ fixed,
+ left,
+ isAllSelected,
+ onToggleSelectAll,
+ onReverseSelection,
+ disabled,
+ sortField,
+ sortDirection,
+ onSortBySelected,
+ filter,
+ showFilter,
+ onFilterIconClick,
+}) => (
+
+
+
+
+
+
+
+
+ {onSortBySelected && (
+
+
+
+ )}
+
+
+)
+
+SelectionCheckboxHeaderCell.propTypes = {
+ disabled: PropTypes.bool,
+ filter: PropTypes.node,
+ fixed: PropTypes.bool,
+ isAllSelected: PropTypes.bool,
+ left: PropTypes.string,
+ showFilter: PropTypes.bool,
+ sortDirection: PropTypes.string,
+ sortField: PropTypes.string,
+ onFilterIconClick: PropTypes.func,
+ onReverseSelection: PropTypes.func,
+ onSortBySelected: PropTypes.func,
+ onToggleSelectAll: PropTypes.func,
+}
+
+export const SelectionCheckboxCell = ({
+ fixed,
+ left,
+ width,
+ className,
+ isSelected,
+ isHovered,
+ onToggle,
+}) => (
+
+ {
+ e.stopPropagation()
+ onToggle(e)
+ }}
+ />
+
+)
+
+SelectionCheckboxCell.propTypes = {
+ className: PropTypes.string,
+ fixed: PropTypes.bool,
+ isHovered: PropTypes.bool,
+ isSelected: PropTypes.bool,
+ left: PropTypes.string,
+ width: PropTypes.string,
+ onToggle: PropTypes.func,
+}
diff --git a/src/components/datatable/SortableColumnHeader.jsx b/src/components/datatable/SortableColumnHeader.jsx
new file mode 100644
index 0000000000..9f2b4e3573
--- /dev/null
+++ b/src/components/datatable/SortableColumnHeader.jsx
@@ -0,0 +1,48 @@
+import i18n from '@dhis2/d2-i18n'
+import { DataTableColumnHeader } from '@dhis2/ui'
+import PropTypes from 'prop-types'
+import React from 'react'
+import { SortIcon } from '../core/icons.jsx'
+import styles from './styles/DataTable.module.css'
+import TopTooltip from './TopTooltip.jsx'
+
+const SortableColumnHeader = ({
+ name,
+ dataKey,
+ sortField,
+ sortDirection,
+ onSort,
+ dataTestPrefix,
+ ...columnHeaderProps
+}) => (
+
+
+ {name}
+
+
+
+
+
+)
+
+SortableColumnHeader.propTypes = {
+ dataKey: PropTypes.string.isRequired,
+ dataTestPrefix: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ onSort: PropTypes.func.isRequired,
+ sortDirection: PropTypes.string,
+ sortField: PropTypes.string,
+}
+
+export default SortableColumnHeader
diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx
index eb69c3e55d..e62296b6b5 100644
--- a/src/components/datatable/TableContextMenu.jsx
+++ b/src/components/datatable/TableContextMenu.jsx
@@ -167,7 +167,7 @@ const TableContextMenu = ({
zoom: true,
})
)
- onClose()
+ onClose(true)
}}
/>
)}
@@ -183,7 +183,7 @@ const TableContextMenu = ({
zoom: true,
})
)
- onClose()
+ onClose(true)
}}
/>
diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx
index 0acdaa1e88..23ea220e87 100644
--- a/src/components/datatable/TableVirtuosoComponents.jsx
+++ b/src/components/datatable/TableVirtuosoComponents.jsx
@@ -23,10 +23,17 @@ DataTableWithVirtuosoContext.propTypes = {
}),
}
+const onRowMouseDown = (e) => {
+ if (e.shiftKey || e.ctrlKey || e.metaKey) {
+ e.preventDefault()
+ }
+}
+
const DataTableRowWithVirtuosoContext = React.memo(
function DataTableRowWithVirtuosoContext({ context, item, ...props }) {
return (
context.onMouseEnter(item)}
onMouseLeave={context.onMouseLeave}
onContextMenu={(e) => context.onContextMenu(e, item)}
diff --git a/src/components/datatable/__tests__/BottomPanel.spec.jsx b/src/components/datatable/__tests__/BottomPanel.spec.jsx
index 314397bfae..4dbbe13d8b 100644
--- a/src/components/datatable/__tests__/BottomPanel.spec.jsx
+++ b/src/components/datatable/__tests__/BottomPanel.spec.jsx
@@ -1,7 +1,8 @@
-import { render, fireEvent } from '@testing-library/react'
+import { render, fireEvent, screen } from '@testing-library/react'
import React from 'react'
import { Provider } from 'react-redux'
import configureMockStore from 'redux-mock-store'
+import { THEMATIC_LAYER } from '../../../constants/layers.js'
import WindowDimensionsProvider from '../../WindowDimensionsProvider.jsx'
import BottomPanel from '../BottomPanel.jsx'
@@ -11,6 +12,12 @@ jest.mock('../DataTable.jsx', () => {
return DataTableMock
})
+jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({
+ useCachedData: () => ({
+ systemSettings: { keyAnalysisDigitGroupSeparator: ',' },
+ }),
+}))
+
const mockStore = configureMockStore()
// jsdom doesn't implement pointer capture or ResizeObserver
@@ -26,7 +33,7 @@ beforeAll(() => {
const DATA_TABLE_HEIGHT = 300
-const renderBottomPanel = () => {
+const renderBottomPanel = ({ dataTable, mapViews } = {}) => {
const store = mockStore({
ui: {
dataTableHeight: DATA_TABLE_HEIGHT,
@@ -34,8 +41,14 @@ const renderBottomPanel = () => {
selectionFilter: [],
highlightColor: null,
},
- dataTable: 'layer1',
- map: { mapViews: [{ id: 'layer1', name: 'Layer 1' }] },
+ dataTable: dataTable ?? {
+ openIds: ['layer1'],
+ activeLayerId: 'layer1',
+ isPanelVisible: true,
+ },
+ map: {
+ mapViews: mapViews ?? [{ id: 'layer1', name: 'Layer 1' }],
+ },
})
const { container } = render(
@@ -44,7 +57,7 @@ const renderBottomPanel = () => {
)
- return { handle: container.querySelector('.resizeHandle') }
+ return { handle: container.querySelector('.resizeHandle'), store }
}
const getDisplayHeight = () =>
@@ -79,3 +92,86 @@ describe('BottomPanel resize cancel', () => {
expect(getDisplayHeight()).toBe(`${DATA_TABLE_HEIGHT}px`)
})
})
+
+describe('BottomPanel layer selection', () => {
+ const eligibleLayer = (id, name) => ({
+ id,
+ name,
+ layer: THEMATIC_LAYER,
+ isLoaded: true,
+ data: [{}],
+ })
+ const mapViews = [
+ eligibleLayer('layer1', 'Layer 1'),
+ eligibleLayer('layer2', 'Layer 2'),
+ ]
+
+ test('restores the stored activeLayerId as the selected value on mount', () => {
+ renderBottomPanel({
+ dataTable: {
+ openIds: ['layer1', 'layer2'],
+ activeLayerId: 'layer2',
+ isPanelVisible: true,
+ },
+ mapViews,
+ })
+
+ expect(screen.getByTestId('data-table-layer-selector')).toHaveValue(
+ 'layer2'
+ )
+ })
+
+ test('falls back to the last open tab when the stored activeLayerId is stale (e.g. its layer was removed)', () => {
+ renderBottomPanel({
+ dataTable: {
+ openIds: ['layer1', 'layer2'],
+ activeLayerId: 'removed-layer',
+ isPanelVisible: true,
+ },
+ mapViews,
+ })
+
+ expect(screen.getByTestId('data-table-layer-selector')).toHaveValue(
+ 'layer2'
+ )
+ })
+
+ test('selecting an already-open layer dispatches setActiveDataTableLayer only', () => {
+ const { store } = renderBottomPanel({
+ dataTable: {
+ openIds: ['layer1', 'layer2'],
+ activeLayerId: 'layer1',
+ isPanelVisible: true,
+ },
+ mapViews,
+ })
+
+ fireEvent.change(screen.getByTestId('data-table-layer-selector'), {
+ target: { value: 'layer2' },
+ })
+
+ expect(store.getActions()).toEqual([
+ { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer2' },
+ ])
+ })
+
+ test('selecting an eligible-but-not-open layer also opens it', () => {
+ const { store } = renderBottomPanel({
+ dataTable: {
+ openIds: ['layer1'],
+ activeLayerId: 'layer1',
+ isPanelVisible: true,
+ },
+ mapViews,
+ })
+
+ fireEvent.change(screen.getByTestId('data-table-layer-selector'), {
+ target: { value: 'layer2' },
+ })
+
+ expect(store.getActions()).toEqual([
+ { type: 'DATA_TABLE_ACTIVE_LAYER_SET', id: 'layer2' },
+ { type: 'DATA_TABLE_TOGGLE', id: 'layer2' },
+ ])
+ })
+})
diff --git a/src/components/datatable/__tests__/CellValue.spec.jsx b/src/components/datatable/__tests__/CellValue.spec.jsx
new file mode 100644
index 0000000000..9c979af62b
--- /dev/null
+++ b/src/components/datatable/__tests__/CellValue.spec.jsx
@@ -0,0 +1,112 @@
+import { render, screen } from '@testing-library/react'
+import React from 'react'
+import {
+ RENDERER_COLOR,
+ RENDERER_ICON,
+ RENDERER_DATE,
+ RENDERER_ORG_UNIT,
+ RENDERER_ORG_UNIT_NAME,
+ RENDERER_BOOLEAN,
+ TYPE_DATE,
+} from '../../../constants/dataTable.js'
+import CellValue, { getCellRendererFlags } from '../CellValue.jsx'
+
+describe('getCellRendererFlags', () => {
+ test('flags exactly one renderer at a time', () => {
+ expect(getCellRendererFlags(RENDERER_COLOR)).toMatchObject({
+ isColorCell: true,
+ isIconCell: false,
+ isDateCell: false,
+ isBooleanCell: false,
+ })
+ expect(getCellRendererFlags(RENDERER_BOOLEAN)).toMatchObject({
+ isColorCell: false,
+ isBooleanCell: true,
+ })
+ })
+
+ test('isDateOnlyCell is driven by type, independent of renderer', () => {
+ expect(getCellRendererFlags(RENDERER_DATE, TYPE_DATE)).toMatchObject({
+ isDateCell: true,
+ isDateOnlyCell: true,
+ })
+ expect(getCellRendererFlags(RENDERER_DATE, 'datetime')).toMatchObject({
+ isDateCell: true,
+ isDateOnlyCell: false,
+ })
+ })
+})
+
+describe('CellValue', () => {
+ test('formats a plain number with the digit group separator', () => {
+ render(
+
+ )
+ expect(screen.getByText('1,234,567')).toBeInTheDocument()
+ })
+
+ test('leaves a plain string untouched', () => {
+ render()
+ expect(screen.getByText('Bo')).toBeInTheDocument()
+ })
+
+ test('renders an em-dash placeholder for a missing value, regardless of renderer', () => {
+ render()
+ expect(screen.getByText('—')).toBeInTheDocument()
+ })
+
+ test('renders an em-dash placeholder for an undefined value on a renderer-tagged column', () => {
+ render()
+ expect(screen.getByText('—')).toBeInTheDocument()
+ })
+
+ test('lowercases a color value instead of formatting it as a number', () => {
+ render()
+ expect(screen.getByText('#abcdef')).toBeInTheDocument()
+ })
+
+ test('renders an icon thumbnail for an icon column', () => {
+ const { container } = render(
+
+ )
+ expect(container.querySelector('img')).toHaveAttribute(
+ 'src',
+ 'https://server/icons/marker.png'
+ )
+ })
+
+ test('formats a boolean-renderer value as Yes/No', () => {
+ render()
+ expect(screen.getByText('Yes')).toBeInTheDocument()
+ })
+
+ test('formats an org-unit-hierarchy value as a breadcrumb', () => {
+ const idToName = new Map([
+ ['country1', 'Country'],
+ ['ou1', 'Facility'],
+ ])
+ render(
+
+ )
+ expect(screen.getByText('Country / Facility')).toBeInTheDocument()
+ })
+
+ test("formats an org-unit-name value as just the feature's own name", () => {
+ const idToName = new Map([['ou1', 'Facility']])
+ render(
+
+ )
+ expect(screen.getByText('Facility')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx
index 9df0ad1b3c..c79452a634 100644
--- a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx
+++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx
@@ -2,6 +2,7 @@ import { render, fireEvent, screen } from '@testing-library/react'
import React from 'react'
import { Provider } from 'react-redux'
import configureMockStore from 'redux-mock-store'
+import { setDataTableColumnConfig } from '../../../actions/dataTable.js'
import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js'
import ColumnPickerControl from '../controls/ColumnPickerControl.jsx'
@@ -18,8 +19,10 @@ const renderColumnPicker = (props) => {
const result = render(
+ store.dispatch(setDataTableColumnConfig('layer1', config))
+ }
{...props}
/>
@@ -65,6 +68,16 @@ describe('ColumnPicker trigger', () => {
expect(screen.getByLabelText('Value')).toBeChecked()
expect(screen.getByLabelText('Legend')).toBeChecked()
})
+
+ test('pressing Escape closes the popover', () => {
+ renderColumnPicker()
+ openPicker()
+ expect(screen.getByLabelText('Name')).toBeInTheDocument()
+
+ fireEvent.keyDown(window, { key: 'Escape' })
+
+ expect(screen.queryByLabelText('Name')).not.toBeInTheDocument()
+ })
})
describe('ColumnPicker visibility toggling', () => {
@@ -356,6 +369,27 @@ describe('ColumnPicker search', () => {
})
})
+describe('ColumnPicker configName (timeline current-period columns)', () => {
+ test('shows configName instead of the period-specific name, when present', () => {
+ const headersWithConfigName = [
+ ...headers,
+ {
+ name: 'Range (Jan 2023)',
+ configName: 'Range (Current period)',
+ dataKey: 'range',
+ },
+ ]
+ renderColumnPicker({ allHeaders: headersWithConfigName })
+ openPicker()
+ expect(
+ screen.getByLabelText('Range (Current period)')
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByLabelText('Range (Jan 2023)')
+ ).not.toBeInTheDocument()
+ })
+})
+
describe('ColumnPicker defaultHidden headers (e.g. period columns)', () => {
const headersWithHiddenColumn = [
...headers,
diff --git a/src/components/datatable/__tests__/DataTableButton.spec.jsx b/src/components/datatable/__tests__/DataTableButton.spec.jsx
new file mode 100644
index 0000000000..e77d3677ec
--- /dev/null
+++ b/src/components/datatable/__tests__/DataTableButton.spec.jsx
@@ -0,0 +1,71 @@
+import { render, fireEvent, screen } from '@testing-library/react'
+import React from 'react'
+import { Provider } from 'react-redux'
+import configureMockStore from 'redux-mock-store'
+import { THEMATIC_LAYER, EXTERNAL_LAYER } from '../../../constants/layers.js'
+import DataTableButton from '../DataTableButton.jsx'
+
+const mockStore = configureMockStore()
+
+const layer = (id, overrides = {}) => ({
+ id,
+ name: id,
+ layer: THEMATIC_LAYER,
+ isLoaded: true,
+ data: [{}],
+ ...overrides,
+})
+
+const renderButton = ({ dataTable, mapViews }) => {
+ const store = mockStore({
+ dataTable,
+ map: { mapViews },
+ })
+ const result = render(
+
+
+
+ )
+ return { ...result, store }
+}
+
+const CLOSED = { openIds: [] }
+
+describe('DataTableButton', () => {
+ test('is disabled when the map has no eligible layers', () => {
+ renderButton({
+ dataTable: CLOSED,
+ mapViews: [layer('a', { layer: EXTERNAL_LAYER })],
+ })
+ expect(screen.getByText('Data table')).toBeDisabled()
+ })
+
+ test('opens the first eligible layer when none is open yet', () => {
+ const { store } = renderButton({
+ dataTable: CLOSED,
+ mapViews: [layer('a'), layer('b')],
+ })
+ fireEvent.click(screen.getByText('Data table'))
+ expect(store.getActions()).toEqual([
+ { type: 'DATA_TABLE_TOGGLE', id: 'a' },
+ ])
+ })
+
+ test('closes the panel when a table is already open', () => {
+ const { store } = renderButton({
+ dataTable: { openIds: ['a'], isPanelVisible: true },
+ mapViews: [layer('a'), layer('b')],
+ })
+ fireEvent.click(screen.getByText('Data table'))
+ expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_CLOSE' }])
+ })
+
+ test('reopens (without changing what is open) when a table was open but the panel is hidden', () => {
+ const { store } = renderButton({
+ dataTable: { openIds: ['a'], isPanelVisible: false },
+ mapViews: [layer('a'), layer('b')],
+ })
+ fireEvent.click(screen.getByText('Data table'))
+ expect(store.getActions()).toEqual([{ type: 'DATA_TABLE_OPEN' }])
+ })
+})
diff --git a/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx
index f3b50feaff..643e89ffb6 100644
--- a/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx
+++ b/src/components/datatable/__tests__/DateGroupFilterInput.spec.jsx
@@ -1,12 +1,6 @@
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,
@@ -17,8 +11,6 @@ import {
} from '../../../constants/dataTable.js'
import DateGroupFilterInput from '../DateGroupFilterInput.jsx'
-const mockStore = configureMockStore()
-
const DATETIME_VALUES = [
{ value: '2023-05-15 09:00:00.0' },
{ value: '2023-05-15 14:00:00.0' },
@@ -26,24 +18,23 @@ const DATETIME_VALUES = [
]
const renderDateGroupFilter = (props) => {
- const store = mockStore({})
+ const onChange = jest.fn()
+ const onClear = jest.fn()
const result = render(
-
-
-
-
-
+
+
+
)
- return { ...result, store }
+ return { ...result, onChange, onClear }
}
const getInput = () =>
@@ -92,24 +83,19 @@ describe('DateGroupFilterInput - default (collapsed) tree', () => {
})
})
-describe('DateGroupFilterInput - selection dispatches', () => {
- test('checking a year dispatches the full date-group filter shape', () => {
- const { store } = renderDateGroupFilter()
+describe('DateGroupFilterInput - selection calls onChange/onClear', () => {
+ test('checking a year calls onChange with the full date-group filter shape', () => {
+ const { onChange } = 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'],
- },
+ expect(onChange).toHaveBeenCalledWith({
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2023'],
})
})
- test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => {
- const { store } = renderDateGroupFilter({
+ test('unchecking the only selected prefix calls onClear', () => {
+ const { onClear } = renderDateGroupFilter({
filterValue: {
granularity: DATE_GROUPS_GRANULARITY,
prefixes: ['2023'],
@@ -117,15 +103,11 @@ describe('DateGroupFilterInput - selection dispatches', () => {
})
openPopover()
fireEvent.click(screen.getByLabelText('2023'))
- expect(store.getActions()).toContainEqual({
- type: DATA_FILTER_CLEAR,
- layerId: 'layer1',
- fieldId: 'eventdate',
- })
+ expect(onClear).toHaveBeenCalled()
})
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({
+ const { onChange } = renderDateGroupFilter({
filterValue: {
granularity: DATE_GROUPS_GRANULARITY,
prefixes: ['2024'],
@@ -134,14 +116,9 @@ describe('DateGroupFilterInput - selection dispatches', () => {
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'],
- },
+ expect(onChange).toHaveBeenCalledWith({
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: ['2024', '2023-05'],
})
})
})
@@ -193,8 +170,8 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => {
expect(screen.queryByLabelText('No value')).not.toBeInTheDocument()
})
- test('checking "Any value" dispatches the sentinel and clears prior selections', () => {
- const { store } = renderDateGroupFilter({
+ test('checking "Any value" calls onChange with the sentinel and clears prior selections', () => {
+ const { onChange } = renderDateGroupFilter({
options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }],
filterValue: {
granularity: DATE_GROUPS_GRANULARITY,
@@ -203,19 +180,14 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => {
})
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],
- },
+ expect(onChange).toHaveBeenCalledWith({
+ granularity: DATE_GROUPS_GRANULARITY,
+ prefixes: [SENTINEL_ANY_VALUE],
})
})
test('checking "No value" preserves an existing tree selection alongside it', () => {
- const { store } = renderDateGroupFilter({
+ const { onChange } = renderDateGroupFilter({
options: [...DATETIME_VALUES, { value: SENTINEL_NO_VALUE }],
filterValue: {
granularity: DATE_GROUPS_GRANULARITY,
@@ -224,19 +196,14 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => {
})
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],
- },
+ expect(onChange).toHaveBeenCalledWith({
+ 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({
+ const { onChange, onClear } = renderDateGroupFilter({
filterValue: {
granularity: DATE_GROUPS_GRANULARITY,
prefixes: [SENTINEL_ANY_VALUE],
@@ -244,13 +211,14 @@ describe('DateGroupFilterInput - "Any value" / "No value"', () => {
})
openPopover()
fireEvent.click(screen.getByLabelText('2023'))
- expect(store.getActions()).toEqual([])
+ expect(onChange).not.toHaveBeenCalled()
+ expect(onClear).not.toHaveBeenCalled()
})
})
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({
+ test('clearing the closed trigger (showing "N selected") calls onClear', () => {
+ const { onClear } = renderDateGroupFilter({
filterValue: {
granularity: DATE_GROUPS_GRANULARITY,
prefixes: ['2023'],
@@ -258,15 +226,11 @@ describe('DateGroupFilterInput - clearing via the input’s clear ("x") button',
})
expect(getInput()).toHaveValue('1 selected')
fireEvent.change(getInput(), { target: { value: '' } })
- expect(store.getActions()).toContainEqual({
- type: DATA_FILTER_CLEAR,
- layerId: 'layer1',
- fieldId: 'eventdate',
- })
+ expect(onClear).toHaveBeenCalled()
})
- test('clearing a typed search narrow while a selection is active also clears the selection (mirrors the flat filter variant)', () => {
- const { store } = renderDateGroupFilter({
+ test('clearing a typed search narrow while a selection is active also calls onClear (mirrors the flat filter variant)', () => {
+ const { onClear } = renderDateGroupFilter({
filterValue: {
granularity: DATE_GROUPS_GRANULARITY,
prefixes: ['2023'],
@@ -277,18 +241,15 @@ describe('DateGroupFilterInput - clearing via the input’s clear ("x") button',
expect(screen.queryByLabelText('2024')).not.toBeInTheDocument()
fireEvent.change(getInput(), { target: { value: '' } })
- expect(store.getActions()).toContainEqual({
- type: DATA_FILTER_CLEAR,
- layerId: 'layer1',
- fieldId: 'eventdate',
- })
+ expect(onClear).toHaveBeenCalled()
})
- test('clearing empty search text with no active filter dispatches nothing', () => {
- const { store } = renderDateGroupFilter()
+ test('clearing empty search text with no active filter calls neither onChange nor onClear', () => {
+ const { onChange, onClear } = renderDateGroupFilter()
openPopover()
fireEvent.change(getInput(), { target: { value: '' } })
- expect(store.getActions()).toEqual([])
+ expect(onChange).not.toHaveBeenCalled()
+ expect(onClear).not.toHaveBeenCalled()
})
})
@@ -312,34 +273,24 @@ describe('DateGroupFilterInput - search', () => {
})
test('typing text with no exact tree match shows a live-applying "Contains" custom filter row', () => {
- const { store } = renderDateGroupFilter()
+ const { onChange } = 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',
- })
+ expect(onChange).toHaveBeenCalledWith('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()
+ const { onChange } = 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',
- })
+ expect(onChange).toHaveBeenCalledWith('2023')
})
})
diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx
index 19fe751014..0543065656 100644
--- a/src/components/datatable/__tests__/FilterInput.spec.jsx
+++ b/src/components/datatable/__tests__/FilterInput.spec.jsx
@@ -3,6 +3,7 @@ import React from 'react'
import { Provider } from 'react-redux'
import { VirtuosoMockContext } from 'react-virtuoso'
import configureMockStore from 'redux-mock-store'
+import { setDataFilter, clearDataFilter } from '../../../actions/dataFilters.js'
import {
DATA_FILTER_SET,
DATA_FILTER_CLEAR,
@@ -30,11 +31,12 @@ const mockStore = configureMockStore()
const renderFilterInput = (props, dataFilters) => {
const store = mockStore({
- dataTable: 'layer1',
map: {
mapViews: [{ id: 'layer1', dataFilters: dataFilters || {} }],
},
})
+ const dataKey = props?.dataKey ?? 'name'
+ const filterValue = (dataFilters || {})[dataKey]
// The checkbox list is virtualized (react-virtuoso)
const result = render(
@@ -45,6 +47,13 @@ const renderFilterInput = (props, dataFilters) => {
dataKey="name"
name="Name"
type="string"
+ filterValue={filterValue}
+ onChange={(value) =>
+ store.dispatch(setDataFilter('layer1', dataKey, value))
+ }
+ onClear={() =>
+ store.dispatch(clearDataFilter('layer1', dataKey))
+ }
{...props}
/>
@@ -354,6 +363,48 @@ describe('FilterInput multi-select path (optionSetId)', () => {
})
})
+describe('FilterInput multi-select path (resolvedOptionNames pre-resolved)', () => {
+ const options = [{ value: 'CONFIRMED' }, { value: 'PROBABLE' }]
+
+ beforeEach(() => {
+ useOptionSet.mockReturnValue({ optionSet: null })
+ })
+
+ test('uses resolvedOptionNames directly without calling useOptionSet', () => {
+ renderFilterInput({
+ dataKey: 'caseType',
+ name: 'Case classification',
+ options,
+ optionSetId: 'optionSet1',
+ resolvedOptionNames: {
+ CONFIRMED: 'Confirmed case',
+ PROBABLE: 'Probable case',
+ },
+ })
+ openPopover('Case classification')
+ expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument()
+ expect(screen.getByLabelText('Probable case')).toBeInTheDocument()
+ expect(useOptionSet).toHaveBeenCalledWith(undefined)
+ })
+
+ test('falls back to useOptionSet when resolvedOptionNames is not provided', () => {
+ useOptionSet.mockReturnValue({
+ optionSet: {
+ options: [{ code: 'CONFIRMED', name: 'Confirmed case' }],
+ },
+ })
+ renderFilterInput({
+ dataKey: 'caseType',
+ name: 'Case classification',
+ options,
+ optionSetId: 'optionSet1',
+ })
+ openPopover('Case classification')
+ expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument()
+ expect(useOptionSet).toHaveBeenCalledWith('optionSet1')
+ })
+})
+
describe('FilterInput searchable popover — search', () => {
const options = [{ value: 'High' }, { value: 'Low' }]
diff --git a/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx
new file mode 100644
index 0000000000..a69e422fe2
--- /dev/null
+++ b/src/components/datatable/__tests__/LayerSelectorControl.spec.jsx
@@ -0,0 +1,40 @@
+import { render, fireEvent, screen } from '@testing-library/react'
+import React from 'react'
+import LayerSelectorControl from '../controls/LayerSelectorControl.jsx'
+
+const layers = [
+ { id: 'layer1', name: 'Layer 1' },
+ { id: 'layer2', name: 'Layer 2' },
+]
+
+const renderControl = (props) =>
+ render(
+
+ )
+
+const getSelect = () => screen.getByTestId('data-table-layer-selector')
+
+describe('LayerSelectorControl', () => {
+ test('lists every eligible layer by name', () => {
+ renderControl()
+ expect(screen.getByText('Layer 1')).toBeInTheDocument()
+ expect(screen.getByText('Layer 2')).toBeInTheDocument()
+ })
+
+ test('shows the active layer id as the selected value', () => {
+ renderControl({ activeLayerId: 'layer2' })
+ expect(getSelect()).toHaveValue('layer2')
+ })
+
+ test('selecting a different layer calls onSelectLayer with its id', () => {
+ const onSelectLayer = jest.fn()
+ renderControl({ onSelectLayer })
+ fireEvent.change(getSelect(), { target: { value: 'layer2' } })
+ expect(onSelectLayer).toHaveBeenCalledWith('layer2')
+ })
+})
diff --git a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx
index 813687c49a..eaa013406c 100644
--- a/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx
+++ b/src/components/datatable/__tests__/OrgUnitGroupFilterInput.spec.jsx
@@ -1,12 +1,6 @@
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,
@@ -14,8 +8,6 @@ import {
} from '../../../constants/dataTable.js'
import OrgUnitGroupFilterInput from '../OrgUnitGroupFilterInput.jsx'
-const mockStore = configureMockStore()
-
const ORG_UNIT_VALUES = [
{ value: '/country1/region1/facility1' },
{ value: '/country1/region2/facility2' },
@@ -23,24 +15,23 @@ const ORG_UNIT_VALUES = [
]
const renderOrgUnitGroupFilter = (props) => {
- const store = mockStore({})
+ const onChange = jest.fn()
+ const onClear = jest.fn()
const result = render(
-
-
-
-
-
+
+
+
)
- return { ...result, store }
+ return { ...result, onChange, onClear }
}
const getInput = () =>
@@ -104,24 +95,19 @@ describe('OrgUnitGroupFilterInput - label resolution', () => {
})
})
-describe('OrgUnitGroupFilterInput - selection dispatches', () => {
- test('checking a root node dispatches the full org-unit-group filter shape', () => {
- const { store } = renderOrgUnitGroupFilter()
+describe('OrgUnitGroupFilterInput - selection calls onChange/onClear', () => {
+ test('checking a root node calls onChange with the full org-unit-group filter shape', () => {
+ const { onChange } = 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'],
- },
+ expect(onChange).toHaveBeenCalledWith({
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
})
})
- test('unchecking the only selected prefix dispatches DATA_FILTER_CLEAR', () => {
- const { store } = renderOrgUnitGroupFilter({
+ test('unchecking the only selected prefix calls onClear', () => {
+ const { onClear } = renderOrgUnitGroupFilter({
filterValue: {
granularity: ORG_UNIT_GROUPS_GRANULARITY,
prefixes: ['/country1'],
@@ -129,11 +115,7 @@ describe('OrgUnitGroupFilterInput - selection dispatches', () => {
})
openPopover()
fireEvent.click(screen.getByLabelText('country1'))
- expect(store.getActions()).toContainEqual({
- type: DATA_FILTER_CLEAR,
- layerId: 'layer1',
- fieldId: 'orgUnitPath',
- })
+ expect(onClear).toHaveBeenCalled()
})
})
@@ -184,8 +166,8 @@ describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => {
expect(screen.queryByLabelText('No value')).not.toBeInTheDocument()
})
- test('checking "Any value" dispatches the sentinel and clears prior selections', () => {
- const { store } = renderOrgUnitGroupFilter({
+ test('checking "Any value" calls onChange with the sentinel and clears prior selections', () => {
+ const { onChange } = renderOrgUnitGroupFilter({
options: [...ORG_UNIT_VALUES, { value: SENTINEL_NO_VALUE }],
filterValue: {
granularity: ORG_UNIT_GROUPS_GRANULARITY,
@@ -194,19 +176,14 @@ describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => {
})
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],
- },
+ expect(onChange).toHaveBeenCalledWith({
+ 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({
+ const { onChange, onClear } = renderOrgUnitGroupFilter({
filterValue: {
granularity: ORG_UNIT_GROUPS_GRANULARITY,
prefixes: [SENTINEL_ANY_VALUE],
@@ -214,7 +191,8 @@ describe('OrgUnitGroupFilterInput - "Any value" / "No value"', () => {
})
openPopover()
fireEvent.click(screen.getByLabelText('country1'))
- expect(store.getActions()).toEqual([])
+ expect(onChange).not.toHaveBeenCalled()
+ expect(onClear).not.toHaveBeenCalled()
})
})
@@ -246,39 +224,33 @@ describe('OrgUnitGroupFilterInput - search', () => {
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()
+ test('typing text with no tree match shows the custom filter row and applies a filter matching nothing, rather than clearing back to unfiltered', () => {
+ const { onChange, onClear } = 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(onChange).toHaveBeenCalledWith({
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: [],
+ searchDerived: true,
+ searchText: 'Nairobi',
})
- expect(store.getActions()).not.toContainEqual(
- expect.objectContaining({ type: DATA_FILTER_SET })
- )
+ expect(onClear).not.toHaveBeenCalled()
})
- test('committing a name-matched custom filter dispatches the matched nodes’ prefixes, not a raw substring match against the id path', () => {
- const { store } = renderOrgUnitGroupFilter({
+ test('committing a name-matched custom filter calls onChange with the matched nodes’ prefixes, not a raw substring match against the id path', () => {
+ const { onChange } = 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',
- },
+ expect(onChange).toHaveBeenCalledWith({
+ granularity: ORG_UNIT_GROUPS_GRANULARITY,
+ prefixes: ['/country1'],
+ searchDerived: true,
+ searchText: 'Sierra',
})
})
diff --git a/src/components/datatable/__tests__/RowCells.spec.jsx b/src/components/datatable/__tests__/RowCells.spec.jsx
deleted file mode 100644
index 7206646534..0000000000
--- a/src/components/datatable/__tests__/RowCells.spec.jsx
+++ /dev/null
@@ -1,122 +0,0 @@
-import { render, screen } from '@testing-library/react'
-import React from 'react'
-import {
- RENDERER_COLOR,
- RENDERER_ICON,
- RENDERER_DATE,
- TYPE_DATE,
-} from '../../../constants/dataTable.js'
-import RowCells from '../RowCells.jsx'
-
-const NAME_HEADER = { dataKey: 'name' }
-
-const defaultProps = {
- visibleHeaders: [NAME_HEADER],
- selectedIdSet: new Set(),
- hoveredFeature: null,
- layerId: 'layer1',
- isCheckboxColumnPinned: false,
- pinnedLeftOffsets: {},
- pinnedColumnCount: 0,
- columnWidths: [],
- rendererByDataKey: new Map(),
- typeByDataKey: new Map(),
- keyAnalysisDigitGroupSeparator: undefined,
- onToggleSelection: jest.fn(),
-}
-
-const renderRow = (row, overrides = {}) =>
- render(
-
- )
-
-describe('RowCells', () => {
- it('renders a plain formatted value cell by default', () => {
- renderRow([
- { dataKey: 'id', value: 'row1' },
- { dataKey: 'name', value: 'Bo' },
- ])
- expect(screen.getByText('Bo')).toBeInTheDocument()
- })
-
- it('renders a color cell as a lowercased swatch value with a background color', () => {
- renderRow(
- [
- { dataKey: 'id', value: 'row1' },
- { dataKey: 'name', value: '#FF0000' },
- ],
- { rendererByDataKey: new Map([['name', RENDERER_COLOR]]) }
- )
- const cell = screen.getByText('#ff0000')
- expect(cell).toBeInTheDocument()
- expect(cell.closest('td')).toHaveStyle({
- backgroundColor: '#FF0000',
- })
- })
-
- it('renders an icon cell as an image', () => {
- const { container } = renderRow(
- [
- { dataKey: 'id', value: 'row1' },
- { dataKey: 'name', value: 'https://example.com/icon.png' },
- ],
- { rendererByDataKey: new Map([['name', RENDERER_ICON]]) }
- )
- expect(container.querySelector('img')).toHaveAttribute(
- 'src',
- 'https://example.com/icon.png'
- )
- })
-
- it('renders a date-only cell formatted as just the date', () => {
- renderRow(
- [
- { dataKey: 'id', value: 'row1' },
- { dataKey: 'name', value: '2024-03-15T10:30:00' },
- ],
- {
- rendererByDataKey: new Map([['name', RENDERER_DATE]]),
- typeByDataKey: new Map([['name', TYPE_DATE]]),
- }
- )
- expect(screen.getByText('2024-03-15')).toBeInTheDocument()
- })
-
- it('renders a datetime cell formatted with the time', () => {
- renderRow(
- [
- { dataKey: 'id', value: 'row1' },
- { dataKey: 'name', value: '2024-03-15T10:30:00' },
- ],
- { rendererByDataKey: new Map([['name', RENDERER_DATE]]) }
- )
- expect(screen.getByText('2024-03-15 10:30')).toBeInTheDocument()
- })
-
- it('checks the checkbox when the row id is in selectedIdSet', () => {
- renderRow([{ dataKey: 'id', value: 'row1' }], {
- selectedIdSet: new Set(['row1']),
- })
- expect(screen.getByRole('checkbox')).toBeChecked()
- })
-
- it('calls onToggleSelection with the row id when the checkbox is clicked', () => {
- const onToggleSelection = jest.fn()
- renderRow([{ dataKey: 'id', value: 'row1' }], { onToggleSelection })
-
- screen.getByRole('checkbox').click()
-
- expect(onToggleSelection).toHaveBeenCalledWith('row1')
- })
-
- it('skips headers with no matching cell in the row', () => {
- renderRow([{ dataKey: 'id', value: 'row1' }])
- expect(screen.queryByText('Bo')).not.toBeInTheDocument()
- })
-})
diff --git a/src/components/datatable/__tests__/SelectionCheckboxColumn.spec.jsx b/src/components/datatable/__tests__/SelectionCheckboxColumn.spec.jsx
new file mode 100644
index 0000000000..15c702006c
--- /dev/null
+++ b/src/components/datatable/__tests__/SelectionCheckboxColumn.spec.jsx
@@ -0,0 +1,49 @@
+import { render, fireEvent, screen } from '@testing-library/react'
+import React from 'react'
+import { SelectionCheckboxCell } from '../SelectionCheckboxColumn.jsx'
+
+const renderCell = (isSelected, onToggle = jest.fn()) => {
+ render(
+
+ )
+ return screen.getByRole('checkbox')
+}
+
+describe('SelectionCheckboxCell', () => {
+ test('calls onToggle with the click event, carrying shiftKey, on a shift-click', () => {
+ const onToggle = jest.fn()
+ const checkbox = renderCell(false, onToggle)
+
+ fireEvent.click(checkbox, { shiftKey: true })
+
+ expect(onToggle).toHaveBeenCalledTimes(1)
+ expect(onToggle.mock.calls[0][0]).toMatchObject({ shiftKey: true })
+ })
+
+ test('calls onToggle on a plain click, with shiftKey false', () => {
+ const onToggle = jest.fn()
+ const checkbox = renderCell(false, onToggle)
+
+ fireEvent.click(checkbox)
+
+ expect(onToggle).toHaveBeenCalledTimes(1)
+ expect(onToggle.mock.calls[0][0]).toMatchObject({ shiftKey: false })
+ })
+
+ test('lets the native click default through, so the browser keeps its own checked state in sync', () => {
+ const checkbox = renderCell(false)
+
+ const notCancelled = fireEvent.click(checkbox)
+
+ expect(notCancelled).toBe(true)
+ })
+})
diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx
index 0384092cdc..d0ba24e7ca 100644
--- a/src/components/datatable/__tests__/TableContextMenu.spec.jsx
+++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx
@@ -55,8 +55,10 @@ describe('TableContextMenu — view profile menu item', () => {
})
test('dispatches setOrgUnitProfile with the row id for a layer type that supports it', () => {
+ const onClose = jest.fn()
const { store } = renderMenu({
contextMenu: { x: 10, y: 10, featureProps: { id: 'ou1' } },
+ onClose,
})
fireEvent.click(
screen
@@ -67,6 +69,7 @@ describe('TableContextMenu — view profile menu item', () => {
type: ORGANISATION_UNIT_PROFILE_SET,
payload: 'ou1',
})
+ expect(onClose).toHaveBeenCalledWith()
})
})
@@ -97,6 +100,6 @@ describe('TableContextMenu — zoom to filtered features', () => {
zoom: true,
},
})
- expect(onClose).toHaveBeenCalled()
+ expect(onClose).toHaveBeenCalledWith(true)
})
})
diff --git a/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx
index 0134dc4510..8c8f1dfeeb 100644
--- a/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx
+++ b/src/components/datatable/__tests__/TableVirtuosoComponents.spec.jsx
@@ -1,6 +1,8 @@
import { render, fireEvent, screen } from '@testing-library/react'
import React from 'react'
-import { EmptyPlaceholder } from '../TableVirtuosoComponents.jsx'
+import TableComponents, {
+ EmptyPlaceholder,
+} from '../TableVirtuosoComponents.jsx'
const renderPlaceholder = (context) =>
render(
@@ -59,3 +61,57 @@ describe('EmptyPlaceholder', () => {
expect(screen.queryByText('No features match your filters')).toBeNull()
})
})
+
+describe('TableRow', () => {
+ const TableRow = TableComponents.TableRow
+
+ const renderRow = () =>
+ render(
+
+
+
+ | Bombali Sebora |
+
+
+
+ )
+
+ test('prevents the default mousedown action for a ctrl-click, to avoid triggering native text selection', () => {
+ renderRow()
+ const notCancelled = fireEvent.mouseDown(
+ screen.getByTestId('dhis2-uicore-datatablerow'),
+ {
+ ctrlKey: true,
+ }
+ )
+ expect(notCancelled).toBe(false)
+ })
+
+ test('prevents the default mousedown action for a shift-click', () => {
+ renderRow()
+ const notCancelled = fireEvent.mouseDown(
+ screen.getByTestId('dhis2-uicore-datatablerow'),
+ {
+ shiftKey: true,
+ }
+ )
+ expect(notCancelled).toBe(false)
+ })
+
+ test('leaves a plain mousedown alone', () => {
+ renderRow()
+ const notCancelled = fireEvent.mouseDown(
+ screen.getByTestId('dhis2-uicore-datatablerow')
+ )
+ expect(notCancelled).toBe(true)
+ })
+})
diff --git a/src/components/datatable/__tests__/useRowClickSelection.spec.js b/src/components/datatable/__tests__/useRowClickSelection.spec.js
new file mode 100644
index 0000000000..5846a720e3
--- /dev/null
+++ b/src/components/datatable/__tests__/useRowClickSelection.spec.js
@@ -0,0 +1,225 @@
+import { renderHook } from '@testing-library/react'
+import { useRowClickSelection } from '../useRowClickSelection.js'
+
+const row = (id) => [{ dataKey: 'id', value: id, align: 'left' }]
+
+describe('useRowClickSelection', () => {
+ test('does nothing on a plain click (no modifier)', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b')]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onRowClick(row('a'), { ctrlKey: false, shiftKey: false })
+
+ expect(onToggle).not.toHaveBeenCalled()
+ expect(onSelectRange).not.toHaveBeenCalled()
+ })
+
+ test('toggles a single row on ctrl/cmd-click', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b')]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onRowClick(row('b'), { ctrlKey: true })
+
+ expect(onToggle).toHaveBeenCalledWith('b')
+ expect(onSelectRange).not.toHaveBeenCalled()
+ })
+
+ test('selects a range on shift-click after a prior click', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c'), row('d')]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onRowClick(row('a'), { ctrlKey: true })
+ result.current.onRowClick(row('c'), { shiftKey: true })
+
+ expect(onSelectRange).toHaveBeenCalledWith(['a', 'b', 'c'])
+ })
+
+ test('shift-click with no prior anchor selects (never toggles) just that row', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b')]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onRowClick(row('b'), { shiftKey: true })
+
+ expect(onToggle).not.toHaveBeenCalled()
+ expect(onSelectRange).toHaveBeenCalledWith(['b'])
+ })
+
+ test('a shift-click range keeps the anchor fixed, so a following shift-click recomputes from the same anchor', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c')]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onRowClick(row('a'), { ctrlKey: true }) // anchor = a
+ result.current.onRowClick(row('c'), { shiftKey: true }) // range a-c, anchor stays a
+ result.current.onRowClick(row('b'), { shiftKey: true }) // range a-b, not c-b
+
+ expect(onSelectRange).toHaveBeenNthCalledWith(1, ['a', 'b', 'c'])
+ expect(onSelectRange).toHaveBeenNthCalledWith(2, ['a', 'b'])
+ })
+
+ test('does nothing when the row has no id', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row(null)]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onRowClick(row(null), { ctrlKey: true })
+
+ expect(onToggle).not.toHaveBeenCalled()
+ })
+
+ test('a ctrl-click that selects a row anchors it for a following shift-click', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c')]
+ const selectedIdSet = new Set()
+ const { result } = renderHook(() =>
+ useRowClickSelection({
+ rows,
+ onToggle,
+ onSelectRange,
+ selectedIdSet,
+ })
+ )
+
+ result.current.onRowClick(row('a'), { ctrlKey: true }) // selects a, anchor = a
+ result.current.onRowClick(row('c'), { shiftKey: true })
+
+ expect(onSelectRange).toHaveBeenCalledWith(['a', 'b', 'c'])
+ })
+
+ test('a ctrl-click that deselects a row clears the anchor, so a following shift-click selects just that row', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c')]
+ const selectedIdSet = new Set(['a'])
+ const { result } = renderHook(() =>
+ useRowClickSelection({
+ rows,
+ onToggle,
+ onSelectRange,
+ selectedIdSet,
+ })
+ )
+
+ result.current.onRowClick(row('a'), { ctrlKey: true }) // deselects a, anchor cleared
+ result.current.onRowClick(row('c'), { shiftKey: true })
+
+ expect(onToggle).toHaveBeenCalledWith('a')
+ expect(onSelectRange).toHaveBeenCalledWith(['c'])
+ })
+
+ test('shift-clicking a checkbox selects the range from the anchor, same as shift-clicking the row', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c'), row('d')]
+ const selectedIdSet = new Set()
+ const { result } = renderHook(() =>
+ useRowClickSelection({
+ rows,
+ onToggle,
+ onSelectRange,
+ selectedIdSet,
+ })
+ )
+
+ result.current.onCheckboxToggle('a') // anchor = a
+ result.current.onCheckboxToggle('c', { shiftKey: true })
+
+ expect(onSelectRange).toHaveBeenCalledWith(['a', 'b', 'c'])
+ expect(onToggle).toHaveBeenCalledTimes(1)
+ expect(onToggle).toHaveBeenCalledWith('a')
+ })
+
+ test('shift-clicking a checkbox with no prior anchor selects just that row', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b')]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onCheckboxToggle('b', { shiftKey: true })
+
+ expect(onToggle).not.toHaveBeenCalled()
+ expect(onSelectRange).toHaveBeenCalledWith(['b'])
+ })
+
+ test('checking a row via the checkbox anchors it for a following shift-click, same as ctrl-click', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c')]
+ const selectedIdSet = new Set()
+ const { result } = renderHook(() =>
+ useRowClickSelection({
+ rows,
+ onToggle,
+ onSelectRange,
+ selectedIdSet,
+ })
+ )
+
+ result.current.onCheckboxToggle('a')
+ result.current.onRowClick(row('c'), { shiftKey: true })
+
+ expect(onToggle).toHaveBeenCalledWith('a')
+ expect(onSelectRange).toHaveBeenCalledWith(['a', 'b', 'c'])
+ })
+
+ test('unchecking a row via the checkbox clears the anchor, same as ctrl-click', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c')]
+ const selectedIdSet = new Set(['a'])
+ const { result } = renderHook(() =>
+ useRowClickSelection({
+ rows,
+ onToggle,
+ onSelectRange,
+ selectedIdSet,
+ })
+ )
+
+ result.current.onCheckboxToggle('a')
+ result.current.onRowClick(row('c'), { shiftKey: true })
+
+ expect(onToggle).toHaveBeenCalledWith('a')
+ expect(onSelectRange).toHaveBeenCalledWith(['c'])
+ })
+
+ test('resetAnchor clears the anchor, so a following shift-click selects just that row', () => {
+ const onToggle = jest.fn()
+ const onSelectRange = jest.fn()
+ const rows = [row('a'), row('b'), row('c')]
+ const { result } = renderHook(() =>
+ useRowClickSelection({ rows, onToggle, onSelectRange })
+ )
+
+ result.current.onRowClick(row('a'), { ctrlKey: true }) // anchor = a
+ result.current.resetAnchor()
+ result.current.onRowClick(row('c'), { shiftKey: true })
+
+ expect(onSelectRange).toHaveBeenLastCalledWith(['c'])
+ })
+})
diff --git a/src/components/datatable/__tests__/useRowContextMenuHighlight.spec.js b/src/components/datatable/__tests__/useRowContextMenuHighlight.spec.js
new file mode 100644
index 0000000000..2b0af0a8ea
--- /dev/null
+++ b/src/components/datatable/__tests__/useRowContextMenuHighlight.spec.js
@@ -0,0 +1,98 @@
+import { renderHook } from '@testing-library/react'
+import { useRowContextMenuHighlight } from '../useRowContextMenuHighlight.js'
+
+const leaveEvent = (relatedTagName) => ({
+ relatedTarget: relatedTagName ? { tagName: relatedTagName } : null,
+})
+
+describe('useRowContextMenuHighlight', () => {
+ test('opening the context menu pins the row via onPin', () => {
+ const onPin = jest.fn()
+ const onClear = jest.fn()
+ const { result } = renderHook(() =>
+ useRowContextMenuHighlight({ onPin, onClear })
+ )
+ const row = { id: 'row1' }
+
+ result.current.onContextMenuOpen(row)
+
+ expect(onPin).toHaveBeenCalledWith(row)
+ })
+
+ test('a mouseleave while the menu is open is ignored, even when it would normally clear the highlight', () => {
+ const onPin = jest.fn()
+ const onClear = jest.fn()
+ const { result } = renderHook(() =>
+ useRowContextMenuHighlight({ onPin, onClear })
+ )
+
+ result.current.onContextMenuOpen({ id: 'row1' })
+ result.current.guardedClear(leaveEvent('DIV'))
+
+ expect(onClear).not.toHaveBeenCalled()
+ })
+
+ test('mouseleave clears the highlight normally when no menu is open', () => {
+ const onPin = jest.fn()
+ const onClear = jest.fn()
+ const { result } = renderHook(() =>
+ useRowContextMenuHighlight({ onPin, onClear })
+ )
+
+ result.current.guardedClear(leaveEvent('DIV'))
+
+ expect(onClear).toHaveBeenCalledTimes(1)
+ })
+
+ test('mouseleave between cells of the same row (relatedTarget is a TD) still never clears, menu or no menu', () => {
+ const onPin = jest.fn()
+ const onClear = jest.fn()
+ const { result } = renderHook(() =>
+ useRowContextMenuHighlight({ onPin, onClear })
+ )
+
+ result.current.guardedClear(leaveEvent('TD'))
+
+ expect(onClear).not.toHaveBeenCalled()
+ })
+
+ test('closing the menu without a superseding highlight clears it', () => {
+ const onPin = jest.fn()
+ const onClear = jest.fn()
+ const { result } = renderHook(() =>
+ useRowContextMenuHighlight({ onPin, onClear })
+ )
+
+ result.current.onContextMenuOpen({ id: 'row1' })
+ result.current.onMenuClose(false)
+
+ expect(onClear).toHaveBeenCalledTimes(1)
+ })
+
+ test('closing the menu after a "Zoom to ..." action (highlightChanged=true) preserves the new highlight', () => {
+ const onPin = jest.fn()
+ const onClear = jest.fn()
+ const { result } = renderHook(() =>
+ useRowContextMenuHighlight({ onPin, onClear })
+ )
+
+ result.current.onContextMenuOpen({ id: 'row1' })
+ result.current.onMenuClose(true)
+
+ expect(onClear).not.toHaveBeenCalled()
+ })
+
+ test('after the menu closes, mouseleave clearing resumes normally', () => {
+ const onPin = jest.fn()
+ const onClear = jest.fn()
+ const { result } = renderHook(() =>
+ useRowContextMenuHighlight({ onPin, onClear })
+ )
+
+ result.current.onContextMenuOpen({ id: 'row1' })
+ result.current.onMenuClose(true)
+ result.current.guardedClear(leaveEvent('DIV'))
+
+ expect(onClear).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/src/components/datatable/__tests__/useRowSelection.spec.js b/src/components/datatable/__tests__/useRowSelection.spec.js
new file mode 100644
index 0000000000..61d7a78225
--- /dev/null
+++ b/src/components/datatable/__tests__/useRowSelection.spec.js
@@ -0,0 +1,72 @@
+import { renderHook } from '@testing-library/react'
+import { useRowSelection } from '../useRowSelection.js'
+
+describe('useRowSelection', () => {
+ test('selects every visible row when nothing is selected yet', () => {
+ const onChange = jest.fn()
+ const { result } = renderHook(() =>
+ useRowSelection({
+ selectedIds: [],
+ selectedIdSet: new Set(),
+ allRowIds: ['a', 'b', 'c'],
+ onChange,
+ })
+ )
+
+ expect(result.current.isAllSelected).toBe(false)
+
+ result.current.onToggleSelectAll()
+
+ expect(onChange).toHaveBeenCalledWith(['a', 'b', 'c'])
+ })
+
+ test('deselects every visible row when all are already selected', () => {
+ const onChange = jest.fn()
+ const { result } = renderHook(() =>
+ useRowSelection({
+ selectedIds: ['a', 'b', 'c'],
+ selectedIdSet: new Set(['a', 'b', 'c']),
+ allRowIds: ['a', 'b', 'c'],
+ onChange,
+ })
+ )
+
+ expect(result.current.isAllSelected).toBe(true)
+
+ result.current.onToggleSelectAll()
+
+ expect(onChange).toHaveBeenCalledWith([])
+ })
+
+ test('preserves ids selected outside the current view when toggling off', () => {
+ const onChange = jest.fn()
+ const { result } = renderHook(() =>
+ useRowSelection({
+ selectedIds: ['a', 'b', 'z'],
+ selectedIdSet: new Set(['a', 'b', 'z']),
+ allRowIds: ['a', 'b'],
+ onChange,
+ })
+ )
+
+ result.current.onToggleSelectAll()
+
+ expect(onChange).toHaveBeenCalledWith(['z'])
+ })
+
+ test('reverses the visible selection via onChange', () => {
+ const onChange = jest.fn()
+ const { result } = renderHook(() =>
+ useRowSelection({
+ selectedIds: ['a'],
+ selectedIdSet: new Set(['a']),
+ allRowIds: ['a', 'b', 'c'],
+ onChange,
+ })
+ )
+
+ result.current.onReverseSelection()
+
+ expect(onChange).toHaveBeenCalledWith(['b', 'c'])
+ })
+})
diff --git a/src/components/datatable/__tests__/useSortState.spec.js b/src/components/datatable/__tests__/useSortState.spec.js
new file mode 100644
index 0000000000..a84711fdad
--- /dev/null
+++ b/src/components/datatable/__tests__/useSortState.spec.js
@@ -0,0 +1,62 @@
+import { act, renderHook } from '@testing-library/react'
+import { useSortState } from '../useSortState.js'
+
+describe('useSortState', () => {
+ test('starts sorted by the initial field, ascending - matching what the table shows before any interaction', () => {
+ const { result } = renderHook(() => useSortState('name'))
+
+ expect(result.current.sortField).toBe('name')
+ expect(result.current.sortDirection).toBe('asc')
+ })
+
+ test('cycling a different column 3 times returns to the same field/direction the table started with - not an unsorted state', () => {
+ const { result } = renderHook(() => useSortState('name'))
+
+ act(() => result.current.sortData({ name: 'type' }))
+ expect(result.current).toMatchObject({
+ sortField: 'type',
+ sortDirection: 'asc',
+ })
+
+ act(() => result.current.sortData({ name: 'type' }))
+ expect(result.current).toMatchObject({
+ sortField: 'type',
+ sortDirection: 'desc',
+ })
+
+ act(() => result.current.sortData({ name: 'type' }))
+ expect(result.current).toMatchObject({
+ sortField: 'name',
+ sortDirection: 'asc',
+ })
+ })
+
+ test('cycling the initial/default column itself is a 2-state toggle, since it already is the default', () => {
+ const { result } = renderHook(() => useSortState('name'))
+
+ act(() => result.current.sortData({ name: 'name' }))
+ expect(result.current).toMatchObject({
+ sortField: 'name',
+ sortDirection: 'desc',
+ })
+
+ act(() => result.current.sortData({ name: 'name' }))
+ expect(result.current).toMatchObject({
+ sortField: 'name',
+ sortDirection: 'asc',
+ })
+ })
+
+ test('respects a custom initial sort field as the reset target', () => {
+ const { result } = renderHook(() => useSortState('level'))
+
+ act(() => result.current.sortData({ name: 'name' }))
+ act(() => result.current.sortData({ name: 'name' }))
+ act(() => result.current.sortData({ name: 'name' }))
+
+ expect(result.current).toMatchObject({
+ sortField: 'level',
+ sortDirection: 'asc',
+ })
+ })
+})
diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx
index 83d85a83e7..0a87d6eb13 100644
--- a/src/components/datatable/__tests__/useTableData.spec.jsx
+++ b/src/components/datatable/__tests__/useTableData.spec.jsx
@@ -63,7 +63,7 @@ describe('useTableData headers', () => {
const { headers, rows, isLoading } = result.current
expect(headers).toHaveLength(5)
expect(headers).toMatchObject([
- { name: 'Org unit 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' },
{
@@ -221,7 +221,7 @@ describe('useTableData headers', () => {
const { headers, rows, isLoading } = result.current
expect(headers).toHaveLength(5)
expect(headers).toMatchObject([
- { name: 'Org unit 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' },
{
@@ -283,7 +283,7 @@ describe('useTableData headers', () => {
const { headers, rows, isLoading } = result.current
expect(headers).toHaveLength(9)
expect(headers).toMatchObject([
- { name: 'Org unit 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' },
{
@@ -369,7 +369,7 @@ describe('useTableData headers', () => {
)
const { headers, rows } = result.current
expect(headers).toMatchObject([
- { name: 'Org unit 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' },
@@ -391,7 +391,7 @@ describe('useTableData headers', () => {
)
})
- test('adds a defaultHidden raw-value-only column for every other period, for a timeline thematic layer', () => {
+ test('adds a defaultHidden raw-value-only column for every period, including the current one, for a timeline thematic layer', () => {
const store = {
aggregations: {},
ui: {
@@ -434,21 +434,30 @@ describe('useTableData headers', () => {
}
)
const { headers, rows } = result.current
- expect(headers).not.toContainEqual(
- expect.objectContaining({ dataKey: 'period_202302_rawValue' })
- )
expect(headers).toContainEqual({
name: 'Value (January 2023)',
dataKey: 'period_202301_rawValue',
type: 'number',
defaultHidden: true,
})
+ expect(headers).toContainEqual({
+ name: 'Value (February 2023)',
+ dataKey: 'period_202302_rawValue',
+ type: 'number',
+ defaultHidden: true,
+ })
expect(rows[0]).toContainEqual(
expect.objectContaining({
value: 100,
dataKey: 'period_202301_rawValue',
})
)
+ expect(rows[0]).toContainEqual(
+ expect.objectContaining({
+ value: 200,
+ dataKey: 'period_202302_rawValue',
+ })
+ )
})
test('split-by-period thematic layer has no default current-period column, only defaultHidden period columns', () => {
@@ -486,7 +495,7 @@ describe('useTableData headers', () => {
)
const { headers, rows } = result.current
expect(headers).toMatchObject([
- { name: 'Org unit 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' },
@@ -578,7 +587,7 @@ describe('useTableData headers', () => {
expect(headers).toHaveLength(10)
expect(headers).toMatchObject([
{ name: 'Event Id', dataKey: 'id', type: 'string' },
- { name: 'Org unit Id', dataKey: 'orgUnitId', 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' },
{
@@ -724,7 +733,7 @@ describe('useTableData headers', () => {
expect(headers).toHaveLength(11)
expect(headers).toMatchObject([
{ name: 'Tracked entity Id', dataKey: 'id', type: 'string' },
- { name: 'Org unit Id', dataKey: 'orgUnitId', 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' },
{
@@ -1094,7 +1103,7 @@ describe('useTableData headers', () => {
expect(headers).toHaveLength(7)
expect(headers).toMatchObject([
- { name: 'Org unit 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' },
{
@@ -1257,7 +1266,7 @@ describe('useTableData headers', () => {
expect(headers).toHaveLength(7)
expect(headers).toMatchObject([
- { name: 'Org unit 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' },
{
@@ -2193,7 +2202,7 @@ describe('useTableData globalSearch', () => {
).toBe('evt1')
})
- test('matches a custom ORGANISATION_UNIT-valued attribute on a Tracked entity layer only by its raw stored value, not its resolved name - only "Org unit hierarchy" gets name-aware global search', () => {
+ test('matches a custom ORGANISATION_UNIT-valued attribute on a Tracked entity layer by its resolved name, same as the "Org unit hierarchy" column - fixed an earlier asymmetry where custom org-unit fields were only searchable by their raw stored id', () => {
useOrgUnitAncestorNames.mockReturnValue({
idToName: new Map([['facility9', 'Referral Hospital']]),
loading: false,
@@ -2233,13 +2242,13 @@ describe('useTableData globalSearch', () => {
}
).result
- expect(renderTeiTableData('referral').current.rows).toHaveLength(0)
-
- const { current } = renderTeiTableData('facility9')
+ const { current } = renderTeiTableData('referral')
expect(current.rows).toHaveLength(1)
expect(current.rows[0].find((c) => c.dataKey === 'id').value).toBe(
'tei1'
)
+
+ expect(renderTeiTableData('addis ababa').current.rows).toHaveLength(0)
})
test('shows no rows when nothing matches', () => {
diff --git a/src/components/datatable/controls/ActiveLayerControl.jsx b/src/components/datatable/controls/ActiveLayerControl.jsx
deleted file mode 100644
index 1d62b35e88..0000000000
--- a/src/components/datatable/controls/ActiveLayerControl.jsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import PropTypes from 'prop-types'
-import React, { useCallback, useRef, useState } from 'react'
-import { createPortal } from 'react-dom'
-import { getCssVar } from '../../../util/helpers.js'
-import styles from './styles/ActiveLayerControl.module.css'
-
-const ActiveLayerControl = ({ name }) => {
- const nameRef = useRef(null)
- const [nameTooltipProps, setNameTooltipProps] = useState(null)
-
- const onMouseEnter = useCallback(() => {
- const el = nameRef.current
- if (!el || el.scrollWidth <= el.offsetWidth) {
- return
- }
- const rect = el.getBoundingClientRect()
- const computed = getComputedStyle(el)
- const lineHeight = Number.parseFloat(computed.lineHeight)
- const verticalPadding = getCssVar(
- '--data-table-name-tooltip-vertical-padding'
- )
- setNameTooltipProps({
- top: rect.top + (rect.height - lineHeight) / 2 - verticalPadding,
- left: rect.left,
- color: computed.color,
- fontSize: computed.fontSize,
- fontWeight: computed.fontWeight,
- lineHeight: `${lineHeight}px`,
- paddingLeft: computed.paddingLeft,
- })
- }, [])
-
- const onMouseLeave = useCallback(() => setNameTooltipProps(null), [])
-
- return (
- <>
-
- {name}
-
- {nameTooltipProps &&
- createPortal(
-
- {name}
-
,
- document.body
- )}
- >
- )
-}
-
-ActiveLayerControl.propTypes = {
- name: PropTypes.string,
-}
-
-export default ActiveLayerControl
diff --git a/src/components/datatable/controls/ClearFiltersControl.jsx b/src/components/datatable/controls/ClearFiltersControl.jsx
index 902e0fc7ed..31d42db860 100644
--- a/src/components/datatable/controls/ClearFiltersControl.jsx
+++ b/src/components/datatable/controls/ClearFiltersControl.jsx
@@ -1,20 +1,18 @@
import i18n from '@dhis2/d2-i18n'
-import { IconFilter16 } from '@dhis2/ui'
import PropTypes from 'prop-types'
import React from 'react'
-import styles from './styles/ClearFiltersControl.module.css'
+import { FilterActiveIcon } from '../../core/index.js'
import ToolbarIconButton from './ToolbarIconButton.jsx'
const ClearFiltersControl = ({ disabled, onClick }) => (
-
-
-
-
+
)
diff --git a/src/components/datatable/controls/ColumnPickerControl.jsx b/src/components/datatable/controls/ColumnPickerControl.jsx
index fe7ca0a70f..34c4d1657a 100644
--- a/src/components/datatable/controls/ColumnPickerControl.jsx
+++ b/src/components/datatable/controls/ColumnPickerControl.jsx
@@ -26,8 +26,6 @@ import React, {
useState,
} from 'react'
import { createPortal } from 'react-dom'
-import { useDispatch } from 'react-redux'
-import { setDataTableColumnConfig } from '../../../actions/dataTable.js'
import {
filterHeadersByName,
getDefaultVisibleKeys,
@@ -49,11 +47,10 @@ const EMPTY_HEADERS = []
const EMPTY_KEYS = []
const ColumnPickerControl = React.memo(function ColumnPickerControl({
- layerId,
allHeaders,
columnConfig,
+ onChange,
}) {
- const dispatch = useDispatch()
const anchorRef = useRef(null)
const [isOpen, setIsOpen] = useState(false)
const [activeId, setActiveId] = useState(null)
@@ -101,14 +98,12 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({
)
const updateConfig = (partial) =>
- dispatch(
- setDataTableColumnConfig(layerId, {
- visibleKeys,
- pinnedKeys,
- orderedKeys,
- ...partial,
- })
- )
+ onChange({
+ visibleKeys,
+ pinnedKeys,
+ orderedKeys,
+ ...partial,
+ })
const onToggleVisible = (dataKey, checked) =>
updateConfig({
@@ -129,8 +124,7 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({
visibleKeys: reverseVisibleKeys(headers, visibleKeys),
})
- const onResetToDefaults = () =>
- dispatch(setDataTableColumnConfig(layerId, undefined))
+ const onResetToDefaults = () => onChange(undefined)
const filteredHeaders = useMemo(
() =>
@@ -312,7 +306,7 @@ const ColumnPickerControl = React.memo(function ColumnPickerControl({
})
ColumnPickerControl.propTypes = {
- layerId: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
allHeaders: PropTypes.arrayOf(
PropTypes.shape({
dataKey: PropTypes.string,
diff --git a/src/components/datatable/controls/ColumnRow.jsx b/src/components/datatable/controls/ColumnRow.jsx
index 28628fc7b9..d944040f05 100644
--- a/src/components/datatable/controls/ColumnRow.jsx
+++ b/src/components/datatable/controls/ColumnRow.jsx
@@ -53,7 +53,9 @@ export const ColumnRowFields = ({
{header.name}
+
+ {header.configName ?? header.name}
+
}
checked={isVisible}
onChange={(checked) => onToggleVisible(header.dataKey, checked)}
@@ -85,6 +87,7 @@ ColumnRowFields.propTypes = {
header: PropTypes.shape({
dataKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
+ configName: PropTypes.string,
}).isRequired,
isPinned: PropTypes.bool.isRequired,
isVisible: PropTypes.bool.isRequired,
@@ -145,6 +148,7 @@ ColumnRow.propTypes = {
header: PropTypes.shape({
dataKey: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
+ configName: PropTypes.string,
}).isRequired,
isDragActive: PropTypes.bool.isRequired,
isPinned: PropTypes.bool.isRequired,
diff --git a/src/components/datatable/controls/LayerSelectorControl.jsx b/src/components/datatable/controls/LayerSelectorControl.jsx
new file mode 100644
index 0000000000..def564c737
--- /dev/null
+++ b/src/components/datatable/controls/LayerSelectorControl.jsx
@@ -0,0 +1,33 @@
+import i18n from '@dhis2/d2-i18n'
+import PropTypes from 'prop-types'
+import React from 'react'
+import styles from '../styles/BottomPanel.module.css'
+
+const LayerSelectorControl = ({ layers, activeLayerId, onSelectLayer }) => (
+
+)
+
+LayerSelectorControl.propTypes = {
+ layers: PropTypes.arrayOf(
+ PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string,
+ })
+ ).isRequired,
+ onSelectLayer: PropTypes.func.isRequired,
+ activeLayerId: PropTypes.string,
+}
+
+export default LayerSelectorControl
diff --git a/src/components/datatable/controls/RowCountControl.jsx b/src/components/datatable/controls/RowCountControl.jsx
index 274ab4fd1d..2284b416d5 100644
--- a/src/components/datatable/controls/RowCountControl.jsx
+++ b/src/components/datatable/controls/RowCountControl.jsx
@@ -1,26 +1,38 @@
import i18n from '@dhis2/d2-i18n'
import PropTypes from 'prop-types'
import React from 'react'
+import { formatWithSeparator } from '../../../util/numbers.js'
import styles from './styles/RowCountControl.module.css'
-const RowCountControl = ({ totalCount, filteredCount }) => {
+const RowCountControl = ({
+ totalCount,
+ filteredCount,
+ keyAnalysisDigitGroupSeparator,
+}) => {
if (totalCount === null || filteredCount === null) {
return null
}
+ const total = formatWithSeparator(
+ totalCount,
+ keyAnalysisDigitGroupSeparator
+ )
+ const filtered = formatWithSeparator(
+ filteredCount,
+ keyAnalysisDigitGroupSeparator
+ )
+
const label =
filteredCount < totalCount
- ? i18n.t('{{filtered}} of {{total}} rows', {
- filtered: filteredCount,
- total: totalCount,
- })
- : i18n.t('{{total}} rows', { total: totalCount })
+ ? i18n.t('{{filtered}} of {{total}} rows', { filtered, total })
+ : i18n.t('{{total}} rows', { total })
return {label}
}
RowCountControl.propTypes = {
filteredCount: PropTypes.number,
+ keyAnalysisDigitGroupSeparator: PropTypes.string,
totalCount: PropTypes.number,
}
diff --git a/src/components/datatable/controls/ShowInViewControl.jsx b/src/components/datatable/controls/ShowInViewControl.jsx
index a1297b0ae7..0922cca3d0 100644
--- a/src/components/datatable/controls/ShowInViewControl.jsx
+++ b/src/components/datatable/controls/ShowInViewControl.jsx
@@ -7,6 +7,8 @@ import ToolbarIconButton from './ToolbarIconButton.jsx'
const ShowInViewControl = ({ active, onClick }) => (
diff --git a/src/components/datatable/controls/__tests__/RowCountControl.spec.jsx b/src/components/datatable/controls/__tests__/RowCountControl.spec.jsx
new file mode 100644
index 0000000000..7c177dbc72
--- /dev/null
+++ b/src/components/datatable/controls/__tests__/RowCountControl.spec.jsx
@@ -0,0 +1,44 @@
+import { render, screen } from '@testing-library/react'
+import React from 'react'
+import RowCountControl from '../RowCountControl.jsx'
+
+describe('RowCountControl', () => {
+ test('renders nothing while counts are not yet known', () => {
+ const { container } = render(
+
+ )
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ test('shows just the total when nothing is filtered out', () => {
+ render()
+ expect(screen.getByText('12345 rows')).toBeInTheDocument()
+ })
+
+ test('shows filtered/total when rows have been filtered out', () => {
+ render()
+ expect(screen.getByText('42 of 12345 rows')).toBeInTheDocument()
+ })
+
+ test('applies the digit-group separator to both numbers', () => {
+ render(
+
+ )
+ expect(screen.getByText('42 of 12,345 rows')).toBeInTheDocument()
+ })
+
+ test('applies the digit-group separator to the total-only label too', () => {
+ render(
+
+ )
+ expect(screen.getByText('12,345 rows')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/datatable/controls/styles/ActiveLayerControl.module.css b/src/components/datatable/controls/styles/ActiveLayerControl.module.css
deleted file mode 100644
index 75d05c3372..0000000000
--- a/src/components/datatable/controls/styles/ActiveLayerControl.module.css
+++ /dev/null
@@ -1,38 +0,0 @@
-:root {
- --data-table-name-tooltip-vertical-padding: 3px;
-}
-
-.layerName {
- font-weight: 500;
- font-size: 12px;
- color: var(--colors-grey800);
- flex: 0 1 auto;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- min-width: 0;
-}
-
-@keyframes tooltipExpandRight {
- from {
- clip-path: inset(0 100% 0 0);
- }
-
- to {
- clip-path: inset(0 0% 0 0);
- }
-}
-
-.nameTooltip {
- animation: tooltipExpandRight 160ms ease-out;
- background: var(--colors-grey100);
- border-radius: 3px;
- -webkit-mask-image: linear-gradient(to left, transparent, black 2em);
- mask-image: linear-gradient(to left, transparent, black 2em);
- padding: var(--data-table-name-tooltip-vertical-padding) 2em
- var(--data-table-name-tooltip-vertical-padding) 0;
- pointer-events: none;
- position: fixed;
- white-space: nowrap;
- z-index: 2000;
-}
diff --git a/src/components/datatable/controls/styles/ColumnPickerControl.module.css b/src/components/datatable/controls/styles/ColumnPickerControl.module.css
index fa974e6fd3..3732301418 100644
--- a/src/components/datatable/controls/styles/ColumnPickerControl.module.css
+++ b/src/components/datatable/controls/styles/ColumnPickerControl.module.css
@@ -1,9 +1,6 @@
.columnPickerPopover {
- padding: var(--spacers-dp8);
+ composes: popoverPanel from './PopoverPanel.module.css';
min-width: 190px;
- background-color: var(--colors-white);
- border-radius: 4px;
- box-shadow: var(--elevations-popover);
}
.searchInput {
diff --git a/src/components/datatable/controls/styles/PopoverPanel.module.css b/src/components/datatable/controls/styles/PopoverPanel.module.css
new file mode 100644
index 0000000000..6751005ee0
--- /dev/null
+++ b/src/components/datatable/controls/styles/PopoverPanel.module.css
@@ -0,0 +1,6 @@
+.popoverPanel {
+ padding: var(--spacers-dp8);
+ background-color: var(--colors-white);
+ border-radius: 4px;
+ box-shadow: var(--elevations-popover);
+}
diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css
index e0002b0700..26a8c3cd0a 100644
--- a/src/components/datatable/styles/BottomPanel.module.css
+++ b/src/components/datatable/styles/BottomPanel.module.css
@@ -40,3 +40,22 @@
background-color: var(--colors-grey300);
flex-shrink: 0;
}
+
+.layerSelect {
+ max-width: 220px;
+ height: 24px;
+ padding: 0 var(--spacers-dp4);
+ font-size: 12px;
+ font-weight: 500;
+ border: 1px solid var(--colors-grey500);
+ border-radius: 3px;
+ background-color: var(--colors-white);
+ flex: 0 1 auto;
+ min-width: 0;
+}
+
+.layerSelect:focus {
+ outline: none;
+ border-color: var(--colors-blue600);
+ box-shadow: inset 0 0 0 2px var(--colors-blue600);
+}
diff --git a/src/components/datatable/styles/DataTableButton.module.css b/src/components/datatable/styles/DataTableButton.module.css
new file mode 100644
index 0000000000..13c63b6e8b
--- /dev/null
+++ b/src/components/datatable/styles/DataTableButton.module.css
@@ -0,0 +1,34 @@
+/* Based on https: //github.com/dhis2/analytics/blob/master/src/components/Toolbar/MenuButton.styles.js */
+
+.button {
+ all: unset;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ line-height: 14px;
+ padding: 0 var(--spacers-dp12);
+ color: var(--colors-grey900);
+ transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
+ cursor: pointer;
+}
+
+.button:hover:enabled,
+.button:active {
+ background-color: var(--colors-grey200);
+}
+
+.button:focus {
+ outline: 3px solid var(--theme-focus);
+ outline-offset: -3px;
+}
+
+/* Prevent focus styles when mouse clicking */
+.button:focus:not(:focus-visible) {
+ outline: none;
+}
+
+.button:disabled {
+ color: var(--colors-grey500);
+ cursor: not-allowed;
+}
diff --git a/src/components/datatable/useGroupFilterInput.js b/src/components/datatable/useGroupFilterInput.js
index 4ccdc0d7ef..87c4d62d17 100644
--- a/src/components/datatable/useGroupFilterInput.js
+++ b/src/components/datatable/useGroupFilterInput.js
@@ -1,6 +1,4 @@
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,
@@ -22,8 +20,8 @@ import { getDropdownPlacement } from './FilterDropdownPopover.jsx'
const identity = (value) => value
const useGroupFilterInput = ({
- dataKey,
- layerId,
+ onChange,
+ onClear,
filterValue,
options,
granularity,
@@ -33,7 +31,6 @@ const useGroupFilterInput = ({
commitSearch,
sanitizeInput = identity,
}) => {
- const dispatch = useDispatch()
const anchorRef = useRef(null)
const listRef = useRef(null)
const [isOpen, setIsOpen] = useState(false)
@@ -63,14 +60,9 @@ const useGroupFilterInput = ({
const applyValues = useCallback(
(nextPrefixes) =>
nextPrefixes.length
- ? dispatch(
- setDataFilter(layerId, dataKey, {
- granularity,
- prefixes: nextPrefixes,
- })
- )
- : dispatch(clearDataFilter(layerId, dataKey)),
- [dispatch, layerId, dataKey, granularity]
+ ? onChange({ granularity, prefixes: nextPrefixes })
+ : onClear(),
+ [onChange, onClear, granularity]
)
const hasNotSetOption = options.some(
@@ -152,10 +144,10 @@ const useGroupFilterInput = ({
const applyCustomFilter = (text) => {
if (!text) {
- dispatch(clearDataFilter(layerId, dataKey))
+ onClear()
return
}
- commitSearch(text, { tree, dispatch, layerId, dataKey })
+ commitSearch(text, { tree, onChange })
}
const onSearchChange = ({ value }) => {
@@ -166,7 +158,7 @@ const useGroupFilterInput = ({
const trimmed = sanitized.trim()
if (trimmed === '') {
if (hasActiveFilter) {
- dispatch(clearDataFilter(layerId, dataKey))
+ onClear()
}
return
}
@@ -238,10 +230,6 @@ const useGroupFilterInput = ({
onEnterKey()
closePopover()
break
- case 'Escape':
- event.preventDefault()
- closePopover()
- break
default:
break
}
diff --git a/src/components/datatable/useRowClickSelection.js b/src/components/datatable/useRowClickSelection.js
new file mode 100644
index 0000000000..376ca30033
--- /dev/null
+++ b/src/components/datatable/useRowClickSelection.js
@@ -0,0 +1,91 @@
+import { useCallback, useRef } from 'react'
+import { getRowClickAction, getRowId } from '../../util/dataTable.js'
+
+export const useRowClickSelection = ({
+ rows,
+ onToggle,
+ onSelectRange,
+ selectedIdSet,
+}) => {
+ const lastClickedRowIndexRef = useRef(null)
+
+ const resetAnchor = useCallback(() => {
+ lastClickedRowIndexRef.current = null
+ }, [])
+
+ const toggleWithAnchor = useCallback(
+ (id, rowIndex) => {
+ const isDeselecting = selectedIdSet?.has(id)
+ onToggle(id)
+ lastClickedRowIndexRef.current = isDeselecting ? null : rowIndex
+ },
+ [onToggle, selectedIdSet]
+ )
+
+ const selectRangeWithAnchor = useCallback(
+ (ids, rowIndex, hadAnchor) => {
+ onSelectRange(ids)
+ if (!hadAnchor) {
+ lastClickedRowIndexRef.current = rowIndex
+ }
+ },
+ [onSelectRange]
+ )
+
+ const onRowClick = useCallback(
+ (row, event) => {
+ const id = getRowId(row)
+
+ if (!id || !rows) {
+ return
+ }
+
+ const rowIndex = rows.findIndex((r) => getRowId(r) === id)
+ const hadAnchor = lastClickedRowIndexRef.current !== null
+ const action = getRowClickAction(event, {
+ id,
+ rowIndex,
+ rows,
+ lastClickedRowIndex: lastClickedRowIndexRef.current,
+ })
+
+ if (!action) {
+ return
+ }
+
+ if (action.type === 'range') {
+ selectRangeWithAnchor(action.ids, rowIndex, hadAnchor)
+ } else {
+ toggleWithAnchor(action.id, rowIndex)
+ }
+ },
+ [rows, selectRangeWithAnchor, toggleWithAnchor]
+ )
+
+ const onCheckboxToggle = useCallback(
+ (id, event) => {
+ if (!id || !rows) {
+ return
+ }
+
+ const rowIndex = rows.findIndex((r) => getRowId(r) === id)
+
+ if (event?.shiftKey) {
+ const hadAnchor = lastClickedRowIndexRef.current !== null
+ const action = getRowClickAction(event, {
+ id,
+ rowIndex,
+ rows,
+ lastClickedRowIndex: lastClickedRowIndexRef.current,
+ })
+ selectRangeWithAnchor(action.ids, rowIndex, hadAnchor)
+ return
+ }
+
+ toggleWithAnchor(id, rowIndex)
+ },
+ [rows, selectRangeWithAnchor, toggleWithAnchor]
+ )
+
+ return { onRowClick, onCheckboxToggle, resetAnchor }
+}
diff --git a/src/components/datatable/useRowContextMenuHighlight.js b/src/components/datatable/useRowContextMenuHighlight.js
new file mode 100644
index 0000000000..77a307ac05
--- /dev/null
+++ b/src/components/datatable/useRowContextMenuHighlight.js
@@ -0,0 +1,38 @@
+import { useCallback, useRef } from 'react'
+import { shouldClearFeatureHighlight } from '../../util/dataTable.js'
+
+export const useRowContextMenuHighlight = ({ onPin, onClear }) => {
+ const menuOpenRef = useRef(false)
+
+ const onContextMenuOpen = useCallback(
+ (row) => {
+ menuOpenRef.current = true
+ onPin(row)
+ },
+ [onPin]
+ )
+
+ const guardedClear = useCallback(
+ (event) => {
+ if (menuOpenRef.current) {
+ return
+ }
+ if (shouldClearFeatureHighlight(event)) {
+ onClear()
+ }
+ },
+ [onClear]
+ )
+
+ const onMenuClose = useCallback(
+ (highlightChanged) => {
+ menuOpenRef.current = false
+ if (!highlightChanged) {
+ onClear()
+ }
+ },
+ [onClear]
+ )
+
+ return { onContextMenuOpen, guardedClear, onMenuClose }
+}
diff --git a/src/components/datatable/useRowSelection.js b/src/components/datatable/useRowSelection.js
index 870bda6bd1..18380b7d49 100644
--- a/src/components/datatable/useRowSelection.js
+++ b/src/components/datatable/useRowSelection.js
@@ -1,6 +1,4 @@
import { useCallback, useMemo } from 'react'
-import { useDispatch } from 'react-redux'
-import { selectAllFeatures, clearSelection } from '../../actions/selection.js'
export const getReversedSelection = (selectedIds, allRowIds) => {
const selectedIdSet = new Set(selectedIds)
@@ -14,10 +12,8 @@ export const useRowSelection = ({
selectedIds,
selectedIdSet,
allRowIds,
- layerId,
+ onChange,
}) => {
- const dispatch = useDispatch()
-
const allRowIdSet = useMemo(() => new Set(allRowIds), [allRowIds])
const isAllSelected = useMemo(
@@ -32,22 +28,12 @@ export const useRowSelection = ({
? selectedIds.filter((id) => !allRowIdSet.has(id))
: [...new Set([...selectedIds, ...allRowIds])]
- if (nextIds.length) {
- dispatch(selectAllFeatures(nextIds, layerId))
- } else {
- dispatch(clearSelection())
- }
- }, [dispatch, isAllSelected, allRowIds, allRowIdSet, selectedIds, layerId])
+ onChange(nextIds)
+ }, [isAllSelected, allRowIds, allRowIdSet, selectedIds, onChange])
const onReverseSelection = useCallback(() => {
- const nextIds = getReversedSelection(selectedIds, allRowIds)
-
- if (nextIds.length) {
- dispatch(selectAllFeatures(nextIds, layerId))
- } else {
- dispatch(clearSelection())
- }
- }, [dispatch, selectedIds, allRowIds, layerId])
+ onChange(getReversedSelection(selectedIds, allRowIds))
+ }, [selectedIds, allRowIds, onChange])
return {
isAllSelected,
diff --git a/src/components/datatable/useSortState.js b/src/components/datatable/useSortState.js
new file mode 100644
index 0000000000..2a77e15cc8
--- /dev/null
+++ b/src/components/datatable/useSortState.js
@@ -0,0 +1,25 @@
+import { useCallback, useReducer } from 'react'
+import { SORT_ASCENDING } from '../../constants/dataTable.js'
+import { getNextSorting } from '../../util/dataTable.js'
+
+export const useSortState = (initialSortField = 'name') => {
+ const [{ sortField, sortDirection }, setSorting] = useReducer(
+ (sorting, newSorting) => ({ ...sorting, ...newSorting }),
+ { sortField: initialSortField, sortDirection: SORT_ASCENDING }
+ )
+
+ const sortData = useCallback(
+ ({ name }) => {
+ setSorting(
+ getNextSorting(
+ name,
+ { sortField, sortDirection },
+ { defaultSortField: initialSortField }
+ )
+ )
+ },
+ [sortField, sortDirection, initialSortField]
+ )
+
+ return { sortField, sortDirection, sortData }
+}
diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js
index 55f62b606d..f384e884fd 100644
--- a/src/components/datatable/useTableData.js
+++ b/src/components/datatable/useTableData.js
@@ -3,8 +3,6 @@ import { useDeferredValue, useMemo, useRef } from 'react'
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'
@@ -25,14 +23,14 @@ import { buildKnownOrgUnitNames } from '../../util/orgUnits.js'
import {
buildRowCells,
getColumnDistinctValues,
+ sortColumnOptions,
} from '../../util/tableColumns.js'
import {
- TYPE_STRING,
ERROR_NON_HOMOGENOUS_FEATURES,
getHeadersForLayer,
} from '../../util/tableHeaders.js'
import { ERROR_NO_VALID_DATA, buildTableData } from '../../util/tableRows.js'
-import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js'
+import { compareRows } from '../../util/tableSort.js'
const ERROR_NO_HEADERS = 'NO_HEADERS'
@@ -82,6 +80,8 @@ export const useTableData = ({
legend,
styleDataItem,
countEventsOutsideOrgUnits,
+ bands,
+ band,
data,
dataWithoutCoords,
dataFilters,
@@ -177,6 +177,8 @@ export const useTableData = ({
countEventsOutsideOrgUnits,
aggregationType,
legend,
+ bands,
+ band,
data: dataWithAggregations,
rawData: data,
}
@@ -200,6 +202,8 @@ export const useTableData = ({
legend,
styleDataItem,
countEventsOutsideOrgUnits,
+ bands,
+ band,
dataWithAggregations,
data,
layerHeaders,
@@ -217,30 +221,14 @@ export const useTableData = ({
)
// Cheap: just re-orders each column's already-known distinct-value list
- const columnOptions = useMemo(() => {
- if (!columnDistinctValues) {
- return EMPTY_COLUMN_OPTIONS
- }
-
- const result = {}
- Object.entries(columnDistinctValues).forEach(
- ([dataKey, { values, type }]) => {
- const direction =
- dataKey === sortField ? sortDirection : SORT_ASCENDING
- result[dataKey] = [...values]
- .sort((a, b) =>
- compareColumnOptionValues(a, b, {
- dataKey,
- type,
- direction,
- })
- )
- .map((value) => ({ value }))
- }
- )
-
- return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS
- }, [columnDistinctValues, sortField, sortDirection])
+ const columnOptions = useMemo(
+ () =>
+ sortColumnOptions(columnDistinctValues, {
+ sortField,
+ sortDirection,
+ }) ?? EMPTY_COLUMN_OPTIONS,
+ [columnDistinctValues, sortField, sortDirection]
+ )
const orgUnitPathValues = useMemo(
() =>
@@ -269,24 +257,13 @@ export const useTableData = ({
return null
}
- if (!headers.length) {
- errorCode.current = ERROR_NO_HEADERS
- return null
- }
-
let filteredData = filterData(dataWithAggregations, dataFilters)
if (globalSearch?.trim()) {
- const stringDataKeys = headers
- .filter((h) => h.type === TYPE_STRING)
- .map((h) => h.dataKey)
- const orgUnitDataKeys = headers
- .filter((h) => h.type === TYPE_ORG_UNIT)
- .map((h) => h.dataKey)
filteredData = filterByGlobalSearch(filteredData, globalSearch, {
- stringDataKeys,
- orgUnitDataKeys,
- idToName: orgUnitIdToName,
+ headers,
+ orgUnitIdToName,
+ keyAnalysisDigitGroupSeparator,
})
}
diff --git a/src/components/layers/overlays/OverlayCard.jsx b/src/components/layers/overlays/OverlayCard.jsx
index a490f28cda..ecf981a97a 100644
--- a/src/components/layers/overlays/OverlayCard.jsx
+++ b/src/components/layers/overlays/OverlayCard.jsx
@@ -5,6 +5,7 @@ import i18n from '@dhis2/d2-i18n'
import PropTypes from 'prop-types'
import React, { useState } from 'react'
import { connect } from 'react-redux'
+import { clearDataFilters } from '../../../actions/dataFilters.js'
import { toggleDataTable } from '../../../actions/dataTable.js'
import {
editLayer,
@@ -36,6 +37,46 @@ import DataDownloadDialog from '../download/DataDownloadDialog.jsx'
import LayerCard from '../LayerCard.jsx'
import styles from './styles/OverlayCard.module.css'
+const getCardContent = ({ loadError, legend }) => {
+ if (loadError) {
+ return (
+
+
+
+ )
+ }
+ return (
+ legend && (
+
+
+
+ )
+ )
+}
+
+const getOpenAsHandler = (layer, baseUrl, setCurrentAO) => async (type) => {
+ const currentAO = getAnalyticalObjectFromThematicLayer(layer)
+
+ // Store AO in user data store
+ await setCurrentAO(currentAO)
+
+ // Open it in another app
+ window.open(
+ `${baseUrl}/${APP_URLS[type]}/#/currentAnalyticalObject`,
+ '_blank'
+ )
+}
+
+const getTitle = (isLoaded, name) =>
+ isLoaded ? name : i18n.t('Loading layer') + '...'
+
+const getSubtitle = (isLoaded, legend) =>
+ isLoaded && legend?.period ? legend.period : null
+
+const ifAllowed = (allowed, handler) => (allowed ? handler : undefined)
+
const OverlayCard = ({
layer,
editLayer,
@@ -45,6 +86,7 @@ const OverlayCard = ({
toggleLayerExpand,
toggleLayerVisibility,
toggleDataTable,
+ clearDataFilters,
}) => {
const [showDataDownloadDialog, setShowDataDownloadDialog] = useState(false)
const { baseUrl } = useConfig()
@@ -61,52 +103,33 @@ const OverlayCard = ({
layer: layerType,
isLoaded,
loadError,
+ dataFilters,
} = layer
const canEdit = layerType !== EXTERNAL_LAYER
const canToggleDataTable = DATA_TABLE_LAYER_TYPES.includes(layerType)
const canDownload = DOWNLOADABLE_LAYER_TYPES.includes(layerType)
const canOpenAs = OPEN_AS_LAYER_TYPES.includes(layerType)
-
- const getCardContent = () => {
- if (loadError) {
- return (
-
-
-
- )
- }
- return (
- legend && (
-
-
-
- )
- )
- }
+ const hasDataFilters = Object.keys(dataFilters ?? {}).length > 0
return (
<>
toggleLayerExpand(id)}
- onEdit={canEdit ? () => editLayer(layer) : undefined}
- toggleDataTable={
- canToggleDataTable ? () => toggleDataTable(id) : undefined
- }
+ onEdit={ifAllowed(canEdit, () => editLayer(layer))}
+ toggleDataTable={ifAllowed(canToggleDataTable, () =>
+ toggleDataTable(id)
+ )}
+ onClearDataFilters={ifAllowed(hasDataFilters, () =>
+ clearDataFilters(id)
+ )}
toggleLayerVisibility={() => toggleLayerVisibility(id)}
onOpacityChange={(newOpacity) =>
changeLayerOpacity(id, newOpacity)
@@ -118,31 +141,16 @@ const OverlayCard = ({
msg: i18n.t('{{- name}} deleted.', { name }),
})
}}
- downloadData={
- canDownload
- ? () => setShowDataDownloadDialog(true)
- : undefined
- }
- openAs={
- canOpenAs
- ? async (type) => {
- const currentAO =
- getAnalyticalObjectFromThematicLayer(layer)
-
- // Store AO in user data store
- await set(currentAO)
-
- // Open it in another app
- window.open(
- `${baseUrl}/${APP_URLS[type]}/#/currentAnalyticalObject`,
- '_blank'
- )
- }
- : undefined
- }
+ downloadData={ifAllowed(canDownload, () =>
+ setShowDataDownloadDialog(true)
+ )}
+ openAs={ifAllowed(
+ canOpenAs,
+ getOpenAsHandler(layer, baseUrl, set)
+ )}
hasError={!!loadError}
>
- {getCardContent()}
+ {getCardContent({ loadError, legend })}
{showDataDownloadDialog && (
({
const mockStore = configureMockStore()
describe('OverlayCard', () => {
- const renderCard = (name) =>
- render(
-
+ const renderCard = (name, layerOverrides = {}) => {
+ const store = mockStore({
+ dataTable: { openIds: [] },
+ aggregations: {},
+ })
+ const rendered = render(
+
{
isExpanded: true,
isVisible: true,
opacity: 1,
+ ...layerOverrides,
}}
/>
)
+ return { ...rendered, store }
+ }
- // Regression test for DHIS2-19998: special characters in the layer name
- // must not be HTML-escaped in the "deleted" alert (default i18next
- // interpolation escapes "<" to "<").
test('shows the raw layer name with special characters in the removal alert', async () => {
renderCard('Children < 5y & "others"')
@@ -58,4 +62,31 @@ describe('OverlayCard', () => {
msg: 'Children < 5y & "others" deleted.',
})
})
+
+ test('does not show a clear-filters button when the layer has no active dataFilters', () => {
+ const { container } = renderCard('Layer 1')
+ expect(
+ container.querySelector(
+ '[data-test="layer-clear-data-filters-button"]'
+ )
+ ).not.toBeInTheDocument()
+ })
+
+ test('shows a clear-filters button when the layer has active dataFilters, and dispatches clearDataFilters on click', () => {
+ const { container, store } = renderCard('Layer 1', {
+ dataFilters: { population: '>100' },
+ })
+
+ const button = container.querySelector(
+ '[data-test="layer-clear-data-filters-button"]'
+ )
+ expect(button).toBeInTheDocument()
+
+ fireEvent.click(button)
+
+ expect(store.getActions()).toContainEqual({
+ type: 'DATA_FILTERS_CLEAR_ALL',
+ layerId: 'layer1',
+ })
+ })
})
diff --git a/src/components/layers/toolbar/LayerToolbar.jsx b/src/components/layers/toolbar/LayerToolbar.jsx
index 476fcc999e..a19ebdaa29 100644
--- a/src/components/layers/toolbar/LayerToolbar.jsx
+++ b/src/components/layers/toolbar/LayerToolbar.jsx
@@ -3,7 +3,7 @@ import { Tooltip, IconEdit24, IconView24, IconViewOff24 } from '@dhis2/ui'
import cx from 'classnames'
import PropTypes from 'prop-types'
import React from 'react'
-import { IconButton } from '../../core/index.js'
+import { FilterActiveIcon, IconButton } from '../../core/index.js'
import LayerToolbarMoreMenu from './LayerToolbarMoreMenu.jsx'
import OpacitySlider from './OpacitySlider.jsx'
import styles from './styles/LayerToolbar.module.css'
@@ -14,6 +14,7 @@ const LayerToolbar = ({
isVisible,
onOpacityChange,
toggleLayerVisibility,
+ onClearDataFilters,
hasError,
...expansionMenuProps
}) => {
@@ -60,11 +61,25 @@ const LayerToolbar = ({
/>