diff --git a/i18n/en.pot b/i18n/en.pot index 856dbe34a6..5f7fed09f9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-09-04T10:42:40.441Z\n" -"PO-Revision-Date: 2026-09-04T10:42:40.441Z\n" +"POT-Creation-Date: 2026-09-04T12:03:11.272Z\n" +"PO-Revision-Date: 2026-09-04T12:03:11.272Z\n" msgid "2020" msgstr "2020" @@ -155,53 +155,6 @@ msgstr "Operator" msgid "Date" msgstr "Date" -msgid "{{filtered}} of {{total}} rows" -msgstr "{{filtered}} of {{total}} rows" - -msgid "{{total}} rows" -msgstr "{{total}} rows" - -msgid "Restore" -msgstr "Restore" - -msgid "Collapse" -msgstr "Collapse" - -msgid "Highlight color" -msgstr "Highlight color" - -msgid "Clear filters" -msgstr "Clear filters" - -msgid "Search all columns" -msgstr "Search all columns" - -msgid "Show only features in current map view" -msgstr "Show only features in current map view" - -msgid "Close" -msgstr "Close" - -msgid "Selected" -msgstr "Selected" - -msgid "Not selected" -msgstr "Not selected" - -msgid "All" -msgstr "All" - -msgid "{{count}} selected" -msgid_plural "{{count}} selected" -msgstr[0] "{{count}} selected" -msgstr[1] "{{count}} selected" - -msgid "No features match your filters" -msgstr "No features match your filters" - -msgid "No results found" -msgstr "No results found" - msgid "Select all visible rows" msgstr "Select all visible rows" @@ -265,6 +218,15 @@ msgstr "No matches" msgid "No value" msgstr "No value" +msgid "Selected" +msgstr "Selected" + +msgid "Not selected" +msgstr "Not selected" + +msgid "All" +msgstr "All" + msgid "Drill up one level" msgstr "Drill up one level" @@ -286,6 +248,60 @@ msgstr "Zoom to selected features" msgid "Zoom to filtered features" msgstr "Zoom to filtered features" +msgid "No features match your filters" +msgstr "No features match your filters" + +msgid "Clear filters" +msgstr "Clear filters" + +msgid "No results found" +msgstr "No results found" + +msgid "Close" +msgstr "Close" + +msgid "Restore" +msgstr "Restore" + +msgid "Collapse" +msgstr "Collapse" + +msgid "Configure columns" +msgstr "Configure columns" + +msgid "Select all columns" +msgstr "Select all columns" + +msgid "Reset to defaults" +msgstr "Reset to defaults" + +msgid "Drag to reorder" +msgstr "Drag to reorder" + +msgid "Unpin column" +msgstr "Unpin column" + +msgid "Pin column to the left" +msgstr "Pin column to the left" + +msgid "Search across all visible columns" +msgstr "Search across all visible columns" + +msgid "Search all columns" +msgstr "Search all columns" + +msgid "Highlight color" +msgstr "Highlight color" + +msgid "{{filtered}} of {{total}} rows" +msgstr "{{filtered}} of {{total}} rows" + +msgid "{{total}} rows" +msgstr "{{total}} rows" + +msgid "Show only features in current map view" +msgstr "Show only features in current map view" + msgid "Data table is not supported when events are grouped on the server." msgstr "Data table is not supported when events are grouped on the server." @@ -2026,6 +2042,11 @@ msgstr "" msgid "Cannot get authorization token for Google Earth Engine." msgstr "Cannot get authorization token for Google Earth Engine." +msgid "{{count}} selected" +msgid_plural "{{count}} selected" +msgstr[0] "{{count}} selected" +msgstr[1] "{{count}} selected" + msgid "Thematic" msgstr "Thematic" diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 5b9adde54a..ceb7ed56e3 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -32,3 +32,9 @@ export const setHighlightColor = (color) => ({ type: types.HIGHLIGHT_COLOR_SET, color, }) + +export const setDataTableColumnConfig = (layerId, config) => ({ + type: types.DATA_TABLE_COLUMN_CONFIG_SET, + layerId, + config, +}) diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 18a7798a40..5567af53e2 100644 --- a/src/components/core/Checkbox.jsx +++ b/src/components/core/Checkbox.jsx @@ -43,7 +43,7 @@ Checkbox.propTypes = { dataTest: PropTypes.string, dense: PropTypes.bool, disabled: PropTypes.bool, - label: PropTypes.string, + label: PropTypes.node, style: PropTypes.object, tooltip: PropTypes.string, } diff --git a/src/components/core/IconButton.jsx b/src/components/core/IconButton.jsx index c7b4e49b89..9ccb118bff 100644 --- a/src/components/core/IconButton.jsx +++ b/src/components/core/IconButton.jsx @@ -1,36 +1,45 @@ import { Tooltip } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React from 'react' +import React, { forwardRef } from 'react' import styles from './styles/IconButton.module.css' -const IconButton = ({ - tooltip, - onClick, - className, - children, - dataTest, - disabled, - ariaLabel, -}) => { - return ( - - ) -} +const IconButton = forwardRef( + ( + { + tooltip, + onClick, + className, + children, + dataTest, + disabled, + ariaLabel, + }, + ref + ) => { + return ( + + ) + } +) + +IconButton.displayName = 'IconButton' IconButton.propTypes = { ariaLabel: PropTypes.string, diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index b46a6354f5..1a067ea7bd 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -13,7 +13,7 @@ export const SortIcon = ({ direction }) => ( ( { const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) - const nameRef = useRef(null) const isDraggingRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) - const [nameTooltipPos, setNameTooltipPos] = useState(null) const [isCollapsed, setIsCollapsed] = useState(false) const [searchInputValue, setSearchInputValue] = useState('') const globalSearch = useDebouncedValue(searchInputValue, 200) + const [headersByLayer, setHeadersByLayer] = useState(null) const hasActiveFilters = Object.keys(dataFilters).length > 0 || @@ -78,24 +70,38 @@ const BottomPanel = () => { height - getCssVar('--header-height') - getCssVar('--toolbar-height') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight - const displayHeight = isCollapsed ? COLLAPSED_HEIGHT : tableHeight + const collapsedHeight = getCssVar('--data-table-controls-height') + const displayHeight = isCollapsed ? collapsedHeight : tableHeight const toggleCollapsed = useCallback( () => setIsCollapsed((collapsed) => !collapsed), [] ) + const onControlsDoubleClick = useCallback( + (e) => { + if (e.target.closest('button, input, label')) { + return + } + toggleCollapsed() + }, + [toggleCollapsed] + ) + const onResizeStart = useCallback(() => { isDraggingRef.current = true }, []) - const onResize = useCallback((h) => { - setIsCollapsed(h <= MIN_HEIGHT) - document.documentElement.style.setProperty( - '--data-table-height', - `${h <= MIN_HEIGHT ? COLLAPSED_HEIGHT : h}px` - ) - }, []) + const onResize = useCallback( + (h) => { + setIsCollapsed(h <= MIN_HEIGHT) + document.documentElement.style.setProperty( + '--data-table-height', + `${h <= MIN_HEIGHT ? collapsedHeight : h}px` + ) + }, + [collapsedHeight] + ) const onResizeEnd = useCallback( (h) => { @@ -115,6 +121,15 @@ const BottomPanel = () => { setFilteredCount(filtered) }, []) + const onHeadersChange = useCallback((headers, layerId) => { + setHeadersByLayer({ layerId, headers }) + }, []) + + const allHeaders = + headersByLayer?.layerId === activeLayerId + ? headersByLayer.headers + : null + const onClearFilters = useCallback(() => { dispatch(clearDataFilters(activeLayerId)) dispatch(setSelectionFilter([])) @@ -124,25 +139,18 @@ const BottomPanel = () => { } }, [dispatch, activeLayerId, showOnlyFeaturesInView]) - const onNameMouseEnter = 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) - setNameTooltipPos({ - top: rect.top + (rect.height - lineHeight) / 2, - left: rect.left, - color: computed.color, - fontSize: computed.fontSize, - lineHeight: `${lineHeight}px`, - paddingLeft: computed.paddingLeft, - }) - }, []) + const onToggleShowOnlyFeaturesInView = useCallback(() => { + dispatch(toggleShowOnlyFeaturesInView()) + }, [dispatch]) + + const onCloseDataTable = useCallback(() => { + dispatch(closeDataTable()) + }, [dispatch]) - const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) + const onHighlightColorChange = useCallback( + (color) => dispatch(setHighlightColor(color)), + [dispatch] + ) useLayoutEffect(() => { if (isDraggingRef.current) { @@ -174,19 +182,7 @@ const BottomPanel = () => { return () => observer.disconnect() }, []) - useKeyDown('Escape', () => dispatch(closeDataTable()), true) - - const rowCountLabel = useMemo(() => { - if (totalCount === null || filteredCount === null) { - return null - } - return filteredCount < totalCount - ? i18n.t('{{filtered}} of {{total}} rows', { - filtered: filteredCount, - total: totalCount, - }) - : i18n.t('{{total}} rows', { total: totalCount }) - }, [totalCount, filteredCount]) + useKeyDown('Escape', onCloseDataTable, true) return (
{ >
- + /> - - {activeLayer?.name} - - {nameTooltipPos && - createPortal( -
- {activeLayer?.name} -
, - document.body - )} + - - - - dispatch(setHighlightColor(color)) - } - /> - - - + + + - {rowCountLabel && ( - {rowCountLabel} - )} - {hasActiveFilters && ( - - )} - + + + setSearchInputValue(value)} - className={styles.globalSearch} - onDoubleClick={(e) => e.stopPropagation()} + onChange={setSearchInputValue} /> - - - dispatch(setHighlightColor(color))} - /> - - + + +
{!isCollapsed && (
@@ -331,6 +244,7 @@ const BottomPanel = () => { diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7e05c4b3f8..5b4eb8f5aa 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -1,16 +1,11 @@ import i18n from '@dhis2/d2-i18n' import { - DataTable, DataTableRow, DataTableCell, DataTableColumnHeader, - DataTableHead, - DataTableBody, ComponentCover, CenteredContent, CircularLoader, - Popper, - Portal, IconSync16, } from '@dhis2/ui' import cx from 'classnames' @@ -34,279 +29,38 @@ import { import { SENTINEL_SELECTED_ROW, SORT_ASCENDING, - SORT_DESCENDING, } from '../../constants/dataTable.js' -import { - SELECTION_FILTER_SELECTED, - SELECTION_FILTER_NOT_SELECTED, -} from '../../constants/selection.js' import { isDarkColor } from '../../util/colors.js' +import { + getNextSorting, + getRowClickAction, + getRowId, + isFilterable, + shouldClearFeatureHighlight, +} from '../../util/dataTable.js' import { formatWithSeparator } from '../../util/numbers.js' +import { + getPinnedCellProps, + getPinnedCount, + getPinnedLeftOffsets, + getVisibleHeaders, +} from '../../util/tableColumns.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' -import Checkbox from '../core/Checkbox.jsx' import { SortIcon } from '../core/icons.jsx' -import { - FilterDropdownPopover, - getDropdownPlacement, -} from './FilterDropdownPopover.jsx' import FilterInput from './FilterInput.jsx' +import SelectionFilterButton from './SelectionFilterButton.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 { useRowSelection } from './useRowSelection.js' import { useTableData } from './useTableData.js' -const SELECTION_FILTER_OPTIONS = [ - { value: SELECTION_FILTER_SELECTED, label: i18n.t('Selected') }, - { value: SELECTION_FILTER_NOT_SELECTED, label: i18n.t('Not selected') }, -] - -export const isFilterable = (dataKey, type) => !!type - -const SelectionFilterButton = ({ value, onChange }) => { - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - - const toggleValue = (optionValue) => { - const next = value.includes(optionValue) - ? value.filter((v) => v !== optionValue) - : [...value, optionValue] - onChange(next) - } - - const buttonLabel = - value.length === 0 - ? i18n.t('All') - : i18n.t('{{count}} selected', { count: value.length }) - - const anchorRect = anchorRef.current?.getBoundingClientRect() - const { dropdownPlacement } = getDropdownPlacement(anchorRect) - - return ( - <> - - {isOpen && ( - setIsOpen(false)} - > -
- {SELECTION_FILTER_OPTIONS.map((option) => ( - toggleValue(option.value)} - style={{ margin: '4px 0' }} - /> - ))} -
-
- )} - - ) -} - -SelectionFilterButton.propTypes = { - value: PropTypes.arrayOf(PropTypes.string).isRequired, - onChange: PropTypes.func.isRequired, -} - -const topTooltipModifiers = [{ name: 'offset', options: { offset: [0, 4] } }] - -const TopTooltip = ({ content, children }) => { - const [open, setOpen] = useState(false) - const referenceRef = useRef(null) - const openTimerRef = useRef(null) - const closeTimerRef = useRef(null) - - const onOpen = () => { - clearTimeout(closeTimerRef.current) - openTimerRef.current = setTimeout(() => setOpen(true), 200) - } - - const onClose = () => { - clearTimeout(openTimerRef.current) - closeTimerRef.current = setTimeout(() => setOpen(false), 200) - } - - useEffect( - () => () => { - clearTimeout(openTimerRef.current) - clearTimeout(closeTimerRef.current) - }, - [] - ) - - return ( - - {children} - {open && ( - - -
- {content} -
-
-
- )} -
- ) -} - -TopTooltip.propTypes = { - children: PropTypes.node.isRequired, - content: PropTypes.node.isRequired, -} - -export const shouldClearFeatureHighlight = (event) => - event.relatedTarget?.tagName !== 'TD' - -export const getNextSorting = (name, { sortField, sortDirection }) => { - if (name !== sortField) { - return { sortField: name, sortDirection: SORT_ASCENDING } - } - if (sortDirection === SORT_ASCENDING) { - return { sortField: name, sortDirection: SORT_DESCENDING } - } - return { sortField: null, sortDirection: SORT_ASCENDING } -} - -const getRowId = (row) => - row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId - -export const getRowClickAction = ( - event, - { id, rowIndex, rows, lastClickedRowIndex } -) => { - if (event.shiftKey) { - if (lastClickedRowIndex === null) { - return { type: 'toggle', id } - } - const [start, end] = [lastClickedRowIndex, rowIndex].sort( - (a, b) => a - b - ) - const ids = rows - .slice(start, end + 1) - .map(getRowId) - .filter(Boolean) - return { type: 'range', ids } - } - - if (event.ctrlKey || event.metaKey) { - return { type: 'toggle', id } - } - - return null -} - -const DataTableWithVirtuosoContext = ({ context, ...props }) => ( - -) - -DataTableWithVirtuosoContext.propTypes = { - context: PropTypes.shape({ - layout: PropTypes.string, - }), -} - -const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( - context.onMouseEnter(item)} - onMouseLeave={context.onMouseLeave} - onContextMenu={(e) => context.onContextMenu(e, item)} - onClick={(e) => context.onRowClick(item, e)} - onDoubleClick={() => context.onRowDoubleClick(item)} - {...props} - /> -) - -DataTableRowWithVirtuosoContext.propTypes = { - context: PropTypes.shape({ - onContextMenu: PropTypes.func, - onMouseEnter: PropTypes.func, - onMouseLeave: PropTypes.func, - onRowClick: PropTypes.func, - onRowDoubleClick: PropTypes.func, - }), - item: PropTypes.arrayOf( - PropTypes.shape({ - dataKey: PropTypes.string, - itemId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - }) - ), -} - -const EmptyPlaceholder = ({ context }) => ( - - - -
- {context.totalCount > 0 ? ( - <> - {i18n.t('No features match your filters')} - {context.hasActiveFilters && ( - - )} - - ) : ( - i18n.t('No results found') - )} -
- - - -) - -EmptyPlaceholder.propTypes = { - context: PropTypes.shape({ - hasActiveFilters: PropTypes.bool, - totalCount: PropTypes.number, - onClearFilters: PropTypes.func, - }), -} - -const TableComponents = { - Table: DataTableWithVirtuosoContext, - TableBody: DataTableBody, - TableHead: DataTableHead, - TableRow: DataTableRowWithVirtuosoContext, - EmptyPlaceholder, -} - const Table = ({ availableWidth, onCountChange, + onHeadersChange, globalSearch, onClearFilters, }) => { @@ -425,12 +179,40 @@ const Table = ({ globalSearch, }) + useEffect(() => { + onHeadersChange?.(headers, activeLayerId) + }, [onHeadersChange, headers, activeLayerId]) + + const columnConfig = layer.dataTableColumnConfig + const pinnedKeys = useMemo( + () => columnConfig?.pinnedKeys ?? [], + [columnConfig] + ) + + const visibleHeaders = useMemo( + () => getVisibleHeaders(headers, columnConfig), + [headers, columnConfig] + ) + const { headerRowRef, columnWidths } = useColumnWidths({ availableWidth, - headers, + headers: visibleHeaders, error, }) + const pinnedColumnCount = useMemo( + () => getPinnedCount(visibleHeaders, pinnedKeys), + [visibleHeaders, pinnedKeys] + ) + + const pinnedLeftOffsets = useMemo( + () => getPinnedLeftOffsets(visibleHeaders, pinnedKeys, columnWidths), + [visibleHeaders, pinnedKeys, columnWidths] + ) + const pinnedOffsetsReady = Object.keys(pinnedLeftOffsets).length > 0 + + const isCheckboxColumnPinned = pinnedColumnCount > 0 && pinnedOffsetsReady + useEffect(() => { onCountChange?.(totalCount, filteredCount) }, [onCountChange, totalCount, filteredCount]) @@ -577,6 +359,8 @@ const Table = ({
- {headers.map( - ({ name, dataKey, type, optionSet }, index) => ( - - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - > - - {name} - - - - - - ) + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + + {name} + + + + + + ) + } )} )} @@ -710,10 +512,21 @@ const Table = ({ feature?.id === rowId && feature?.layerId === layer.id + const cellsByDataKey = new Map( + row.map((cell) => [cell.dataKey, cell]) + ) + return ( <> e.stopPropagation()} /> - {row.map(({ dataKey, value, align }) => ( - - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - - ))} + {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, + }) + return ( + + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + + ) + })} ) }} @@ -797,6 +630,7 @@ Table.propTypes = { globalSearch: PropTypes.string, onClearFilters: PropTypes.func, onCountChange: PropTypes.func, + onHeadersChange: PropTypes.func, } export default Table diff --git a/src/components/datatable/FilterHelpTooltip.jsx b/src/components/datatable/FilterHelpTooltip.jsx new file mode 100644 index 0000000000..10e8506580 --- /dev/null +++ b/src/components/datatable/FilterHelpTooltip.jsx @@ -0,0 +1,89 @@ +import { Popper, Portal } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useEffect, useRef, useState } from 'react' +import styles from './styles/FilterHelpTooltip.module.css' + +const helpTooltipModifiers = [ + { name: 'offset', options: { offset: [0, 4] } }, + { name: 'flip', enabled: false }, +] + +const FilterHelpTooltip = ({ + content, + placement, + estimatedHeight, + dataTest, + children, +}) => { + const [open, setOpen] = useState(false) + const referenceRef = useRef(null) + const openTimerRef = useRef(null) + const closeTimerRef = useRef(null) + + const onOpen = () => { + clearTimeout(closeTimerRef.current) + openTimerRef.current = setTimeout(() => setOpen(true), 200) + } + + const onClose = () => { + clearTimeout(openTimerRef.current) + closeTimerRef.current = setTimeout(() => setOpen(false), 200) + } + + useEffect( + () => () => { + clearTimeout(openTimerRef.current) + clearTimeout(closeTimerRef.current) + }, + [] + ) + + const referenceRect = referenceRef.current?.getBoundingClientRect() + let spaceAvailable = Infinity + if (referenceRect) { + spaceAvailable = + placement === 'top' + ? referenceRect.top + : window.innerHeight - referenceRect.bottom + } + const hasRoom = spaceAvailable >= estimatedHeight + + return ( + + {children} + {open && hasRoom && ( + + +
+ {content} +
+
+
+ )} +
+ ) +} + +FilterHelpTooltip.propTypes = { + children: PropTypes.node.isRequired, + content: PropTypes.node.isRequired, + dataTest: PropTypes.string.isRequired, + estimatedHeight: PropTypes.number.isRequired, + placement: PropTypes.oneOf(['top', 'bottom']).isRequired, +} + +export default FilterHelpTooltip diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index ee2fd45156..91d4759221 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,8 +1,8 @@ import i18n from '@dhis2/d2-i18n' -import { Input, Popper, Portal, IconFilter16, IconSync16 } from '@dhis2/ui' +import { Input, IconFilter16, IconSync16 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { useEffect, useRef, useState } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { Virtuoso } from 'react-virtuoso' import { setDataFilter, clearDataFilter } from '../../actions/dataFilters.js' @@ -11,7 +11,13 @@ import { SENTINEL_NO_VALUE, } from '../../constants/dataTable.js' import useOptionSet from '../../hooks/useOptionSet.js' -import { numericFilter } from '../../util/filter.js' +import { + getDisplayValue, + getFilteredOptions, + getPopoverWidth, + getSelectedAndAppliedString, + measureMaxTextWidth, +} from '../../util/filterInput.js' import { getInvertibleValues, reverseSelection, @@ -25,11 +31,11 @@ import { FilterDropdownPopover, getDropdownPlacement, } from './FilterDropdownPopover.jsx' +import FilterHelpTooltip from './FilterHelpTooltip.jsx' import styles from './styles/FilterInput.module.css' const OPTION_ROW_HEIGHT = 28 // Checkbox rows are a fixed height so the list can be virtualized const MAX_LIST_HEIGHT = 260 -const MIN_POPOVER_WIDTH = 140 const NUMERIC_HELP_HEIGHT = 140 const TEXT_HELP_HEIGHT = 56 const NUMERIC_FILTER_HELP = ( @@ -50,124 +56,6 @@ const TEXT_FILTER_HELP = ( ) const NUMERIC_INPUT_DISALLOWED = /[^0-9.\-<>=,&\s]/g -const helpTooltipModifiers = [ - { name: 'offset', options: { offset: [0, 4] } }, - { name: 'flip', enabled: false }, -] - -const FilterHelpTooltip = ({ - content, - placement, - estimatedHeight, - dataTest, - children, -}) => { - const [open, setOpen] = useState(false) - const referenceRef = useRef(null) - const openTimerRef = useRef(null) - const closeTimerRef = useRef(null) - - const onOpen = () => { - clearTimeout(closeTimerRef.current) - openTimerRef.current = setTimeout(() => setOpen(true), 200) - } - - const onClose = () => { - clearTimeout(openTimerRef.current) - closeTimerRef.current = setTimeout(() => setOpen(false), 200) - } - - useEffect( - () => () => { - clearTimeout(openTimerRef.current) - clearTimeout(closeTimerRef.current) - }, - [] - ) - - const referenceRect = referenceRef.current?.getBoundingClientRect() - let spaceAvailable = Infinity - if (referenceRect) { - spaceAvailable = - placement === 'top' - ? referenceRect.top - : window.innerHeight - referenceRect.bottom - } - const hasRoom = spaceAvailable >= estimatedHeight - - return ( - - {children} - {open && hasRoom && ( - - -
- {content} -
-
-
- )} -
- ) -} - -FilterHelpTooltip.propTypes = { - children: PropTypes.node.isRequired, - content: PropTypes.node.isRequired, - dataTest: PropTypes.string.isRequired, - estimatedHeight: PropTypes.number.isRequired, - placement: PropTypes.oneOf(['top', 'bottom']).isRequired, -} - -const getFilteredOptions = ({ - realOptions, - trimmedSearch, - normalizedSearch, - type, - resolveLabel, -}) => { - if (!trimmedSearch) { - return realOptions - } - if (type === 'number') { - return realOptions.filter(({ value }) => - numericFilter(Number(value), trimmedSearch) - ) - } - return realOptions.filter(({ value }) => - resolveLabel(value).toLowerCase().includes(normalizedSearch) - ) -} - -const getDisplayValue = ({ isOpen, searchText, selected, appliedString }) => { - if (isOpen) { - return searchText - } - if (selected.length) { - return i18n.t('{{count}} selected', { count: selected.length }) - } - return appliedString -} - -const getSelectedAndAppliedString = (filterValue) => ({ - selected: Array.isArray(filterValue) ? filterValue : [], - appliedString: typeof filterValue === 'string' ? filterValue : '', -}) - const SearchableFilterPopover = ({ dataKey, name, @@ -196,7 +84,6 @@ const SearchableFilterPopover = ({ const closePopover = () => setIsOpen(false) const anchorRect = anchorRef.current?.getBoundingClientRect() - const anchorWidth = anchorRect?.width const { dropdownPlacement, dropdownSide, tooltipPlacement } = getDropdownPlacement(anchorRect) @@ -226,6 +113,17 @@ const SearchableFilterPopover = ({ const realValues = realOptions.map((o) => o.value) const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + const popoverWidth = useMemo(() => { + const labels = realOptions.map((o) => resolveLabel(o.value)) + if (hasNotSetOption) { + labels.push(resolveLabel(SENTINEL_NO_VALUE)) + } + const font = `11px ${getComputedStyle(document.body).fontFamily}` + const maxLabelWidth = measureMaxTextWidth(labels, font) + return getPopoverWidth(maxLabelWidth) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [realOptions, hasNotSetOption]) + const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) @@ -406,14 +304,7 @@ const SearchableFilterPopover = ({ className={cx(styles.searchableFilterPopover, { [styles.reversedOrder]: dropdownSide === 'top', })} - style={{ - minWidth: anchorWidth - ? `${Math.max( - anchorWidth, - MIN_POPOVER_WIDTH - )}px` - : undefined, - }} + style={{ width: `${popoverWidth}px` }} > {showCustomFilterRow && ( + {isOpen && ( + setIsOpen(false)} + > +
+ {SELECTION_FILTER_OPTIONS.map((option) => ( + toggleValue(option.value)} + className={styles.denseCheckbox} + /> + ))} +
+
+ )} + + ) +} + +SelectionFilterButton.propTypes = { + value: PropTypes.arrayOf(PropTypes.string).isRequired, + onChange: PropTypes.func.isRequired, +} + +export default SelectionFilterButton diff --git a/src/components/datatable/TableVirtuosoComponents.jsx b/src/components/datatable/TableVirtuosoComponents.jsx new file mode 100644 index 0000000000..46dd3b5964 --- /dev/null +++ b/src/components/datatable/TableVirtuosoComponents.jsx @@ -0,0 +1,97 @@ +import i18n from '@dhis2/d2-i18n' +import { + DataTable, + DataTableRow, + DataTableBody, + DataTableHead, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import styles from './styles/TableVirtuosoComponents.module.css' + +const DataTableWithVirtuosoContext = ({ context, ...props }) => ( + +) + +DataTableWithVirtuosoContext.propTypes = { + context: PropTypes.shape({ + layout: PropTypes.string, + }), +} + +const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( + context.onMouseEnter(item)} + onMouseLeave={context.onMouseLeave} + onContextMenu={(e) => context.onContextMenu(e, item)} + onClick={(e) => context.onRowClick(item, e)} + onDoubleClick={() => context.onRowDoubleClick(item)} + {...props} + /> +) + +DataTableRowWithVirtuosoContext.propTypes = { + context: PropTypes.shape({ + onContextMenu: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + onRowClick: PropTypes.func, + onRowDoubleClick: PropTypes.func, + }), + item: PropTypes.arrayOf( + PropTypes.shape({ + dataKey: PropTypes.string, + itemId: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + }) + ), +} + +const EmptyPlaceholder = ({ context }) => ( + + + +
+ {context.totalCount > 0 ? ( + <> + {i18n.t('No features match your filters')} + {context.hasActiveFilters && ( + + )} + + ) : ( + i18n.t('No results found') + )} +
+ + + +) + +EmptyPlaceholder.propTypes = { + context: PropTypes.shape({ + hasActiveFilters: PropTypes.bool, + totalCount: PropTypes.number, + onClearFilters: PropTypes.func, + }), +} + +const TableComponents = { + Table: DataTableWithVirtuosoContext, + TableBody: DataTableBody, + TableHead: DataTableHead, + TableRow: DataTableRowWithVirtuosoContext, + EmptyPlaceholder, +} + +export default TableComponents diff --git a/src/components/datatable/TopTooltip.jsx b/src/components/datatable/TopTooltip.jsx new file mode 100644 index 0000000000..3e7ed0f249 --- /dev/null +++ b/src/components/datatable/TopTooltip.jsx @@ -0,0 +1,63 @@ +import { Popper, Portal } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useEffect, useRef, useState } from 'react' +import styles from './styles/TopTooltip.module.css' + +const topTooltipModifiers = [{ name: 'offset', options: { offset: [0, 4] } }] + +const TopTooltip = ({ content, children }) => { + const [open, setOpen] = useState(false) + const referenceRef = useRef(null) + const openTimerRef = useRef(null) + const closeTimerRef = useRef(null) + + const onOpen = () => { + clearTimeout(closeTimerRef.current) + openTimerRef.current = setTimeout(() => setOpen(true), 200) + } + + const onClose = () => { + clearTimeout(openTimerRef.current) + closeTimerRef.current = setTimeout(() => setOpen(false), 200) + } + + useEffect( + () => () => { + clearTimeout(openTimerRef.current) + clearTimeout(closeTimerRef.current) + }, + [] + ) + + return ( + + {children} + {open && ( + + +
+ {content} +
+
+
+ )} +
+ ) +} + +TopTooltip.propTypes = { + children: PropTypes.node.isRequired, + content: PropTypes.node.isRequired, +} + +export default TopTooltip diff --git a/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx new file mode 100644 index 0000000000..94c25351a7 --- /dev/null +++ b/src/components/datatable/__tests__/ColumnPickerControl.spec.jsx @@ -0,0 +1,357 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { DATA_TABLE_COLUMN_CONFIG_SET } from '../../../constants/actionTypes.js' +import ColumnPickerControl from '../controls/ColumnPickerControl.jsx' + +const mockStore = configureMockStore() + +const headers = [ + { name: 'Name', dataKey: 'name' }, + { name: 'Value', dataKey: 'rawValue' }, + { name: 'Legend', dataKey: 'legend' }, +] + +const renderColumnPicker = (props) => { + const store = mockStore({}) + const result = render( + + + + ) + return { ...result, store } +} + +const openPicker = () => + fireEvent.click(screen.getByTestId('data-table-column-picker-button')) + +describe('ColumnPicker trigger', () => { + test('is disabled when there are no headers yet', () => { + renderColumnPicker({ allHeaders: null }) + expect( + screen.getByTestId('data-table-column-picker-button') + ).toBeDisabled() + }) + + test('is disabled when allHeaders is an empty array', () => { + renderColumnPicker({ allHeaders: [] }) + expect( + screen.getByTestId('data-table-column-picker-button') + ).toBeDisabled() + }) + + test('is enabled once headers are available', () => { + renderColumnPicker() + expect( + screen.getByTestId('data-table-column-picker-button') + ).not.toBeDisabled() + }) + + test('a click on the disabled trigger does not open the popover', () => { + renderColumnPicker({ allHeaders: [] }) + openPicker() + expect(screen.queryByLabelText('Name')).not.toBeInTheDocument() + }) + + test('opens a popover listing every column, checked by default', () => { + renderColumnPicker() + openPicker() + expect(screen.getByLabelText('Name')).toBeChecked() + expect(screen.getByLabelText('Value')).toBeChecked() + expect(screen.getByLabelText('Legend')).toBeChecked() + }) +}) + +describe('ColumnPicker visibility toggling', () => { + test('unchecking a column dispatches visibleKeys without that column, leaving order/pinning untouched', () => { + const { store } = renderColumnPicker() + openPicker() + fireEvent.click(screen.getByLabelText('Value')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('rechecking a hidden column dispatches visibleKeys with it added back, leaving order/pinning untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { visibleKeys: ['name', 'legend'] }, + }) + openPicker() + expect(screen.getByLabelText('Value')).not.toBeChecked() + fireEvent.click(screen.getByLabelText('Value')) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'legend', 'rawValue'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) +}) + +describe('ColumnPicker pinning', () => { + test('pinning a column dispatches pinnedKeys including it, leaving visibility/order untouched', () => { + const { store } = renderColumnPicker() + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-pin-rawValue') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: ['rawValue'], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('unpinning an already-pinned column dispatches pinnedKeys without it, leaving visibility/order untouched', () => { + const { store } = renderColumnPicker({ + columnConfig: { pinnedKeys: ['rawValue'] }, + }) + openPicker() + fireEvent.click( + screen.getByTestId('data-table-column-picker-pin-rawValue') + ) + expect(store.getActions()).toContainEqual({ + type: DATA_TABLE_COLUMN_CONFIG_SET, + layerId: 'layer1', + config: { + visibleKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: [], + orderedKeys: ['name', 'rawValue', 'legend'], + }, + }) + }) + + test('renders pinned columns first, ahead of orderedKeys', () => { + renderColumnPicker({ + columnConfig: { + orderedKeys: ['name', 'rawValue', 'legend'], + pinnedKeys: ['legend'], + }, + }) + openPicker() + const labels = screen + .getAllByRole('checkbox') + .map((el) => el.closest('label')?.textContent) + // Excludes the bulk "select all" checkbox, which isn't + // wrapped in a