diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 67e9e388a..b8c40525e 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -36,7 +36,7 @@ const checkTableCell = ({ row = 0, column = 0, expectedContent }) => { } describe('data table', () => { - it('opens data table and filters and sorts', () => { + it('opens data table for a Thematic layer and filters and sorts', () => { const viewportHeight = Cypress.config('viewportHeight') const expectedBottoms1 = [viewportHeight] const expectedHeights1 = [ @@ -47,7 +47,7 @@ describe('data table', () => { cy.visit(`/#/${map.id}`) cy.get('canvas', EXTENDED_TIMEOUT).should('be.visible') - //check that the map resizes properly + // Check that the map resizes properly assertMapPosition(expectedBottoms1, expectedHeights1) cy.getByDataTest('moremenubutton').first().click() @@ -62,10 +62,10 @@ describe('data table', () => { .contains('Show data table') .click() - //check that the bottom panel is present + // Check that the bottom panel is present cy.getByDataTest('bottom-panel').should('be.visible') - //check that the map resizes properly + // Check that the map resizes properly cy.getByDataTest('bottom-panel') .invoke('height') .then((height) => { @@ -80,25 +80,25 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // check number of columns + // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 11) + .should('have.length', 10) // Filter by name - cy.getByDataTest('data-table-column-filter-input-Name') + cy.getByDataTest('data-table-column-filter-search-Name') .find('input') - .type('bar') + .type('bar{enter}') - // check that the filter returned the correct number of rows + // Check that the filter returned the correct number of rows cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 7) - // confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) - checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) + // Confirm that the sort order is initially ascending by Name + checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' }) + checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) // Sort by name cy.getByDataTest('data-table-column-sort-button-Name').click() @@ -108,16 +108,16 @@ describe('data table', () => { // so we reset to top before asserting on row indices below cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - // confirm that the rows are sorted by Name descending - checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) - checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) + // Confirm that the rows are sorted by Name descending + checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' }) - // filter by Value (numeric) - cy.getByDataTest('data-table-column-filter-input-Value') + // Filter by Value (numeric) + cy.getByDataTest('data-table-column-filter-search-Value') .find('input') - .type('>26') + .type('>26{enter}') - // check that the (combined) filter returned the correct number of rows + // Check that the (combined) filter returned the correct number of rows cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -129,11 +129,11 @@ describe('data table', () => { // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - // check that the rows are sorted by Value ascending - checkTableCell({ row: 0, column: 4, expectedContent: '35' }) - checkTableCell({ row: 4, column: 4, expectedContent: '76' }) + // Check that the rows are sorted by Value ascending + checkTableCell({ row: 0, column: 3, expectedContent: '35' }) + checkTableCell({ row: 4, column: 3, expectedContent: '76' }) - // right-click a row and select "View profile" + // Right-click a row and select "View profile" cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -142,26 +142,26 @@ describe('data table', () => { cy.getByDataTest('data-table-context-menu-view-profile').click() - // check that the org unit profile drawer is opened + // Check that the org unit profile drawer is opened cy.getByDataTest('org-unit-profile').should('be.visible') cy.getByDataTest('layers-toggle-button').click() - // close the datatable + // Close the datatable cy.getByDataTest('moremenubutton').first().click() cy.getByDataTest('more-menu') .find('li') .contains('Hide data table') .click() - //check that the bottom panel is closed + // Check that the bottom panel is closed cy.getByDataTest('bottom-panel').should('not.exist') - //check that the map resizes properly + // Check that the map resizes properly assertMapPosition(expectedBottoms1, expectedHeights1) }) - it('opens the data table for an Event layer', () => { + it('opens data table for an Event layer', () => { cy.visit('/') const EvenLayer = new EventLayer() @@ -192,56 +192,59 @@ describe('data table', () => { // Collapse the Layers Panel to give the table more width cy.getByDataTest('layers-toggle-button').click() - // check number of columns + // Check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 11) + .should('have.length', 10) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') .contains('Age in years', { matchCase: false }) .should('be.visible') - // filter by Org unit + // Filter by Org unit const ouName = 'Moyowa' - cy.getByDataTest('data-table-column-filter-input-Org unit') + cy.getByDataTest('data-table-column-filter-search-Org unit') .find('input') - .type(ouName) + .type(`${ouName}{enter}`) - // check that all the rows have Org unit Moyowa - checkTableCell({ row: 0, column: 2, expectedContent: ouName }) - checkTableCell({ row: 2, column: 2, expectedContent: ouName }) + // Check that all the rows have Org unit Moyowa + checkTableCell({ row: 0, column: 1, expectedContent: ouName }) + checkTableCell({ row: 2, column: 1, expectedContent: ouName }) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 3) - // filter by Mode of Discharge - cy.getByDataTest('data-table-column-filter-input-Mode of Discharge') + // Filter by Mode of Discharge + cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') .find('input') .type('Absconded') + cy.contains('label', 'Absconded').click() cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 1) - cy.getByDataTest('data-table-column-filter-input-Mode of Discharge') - .find('input') - .clear() + cy.get('.backdrop').last().click() + + cy.getByDataTest('data-table-column-filter-search-Mode of Discharge') + .find('.clear-button') + .click() cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .should('have.length', 3) - // filter by Age in years (numeric) - cy.getByDataTest('data-table-column-filter-input-Age in years') + // Filter by Age in years (numeric) + cy.getByDataTest('data-table-column-filter-search-Age in years') .find('input') - .type('<51') + .type('<51{enter}') - // check that the filter returned the correct number of rows + // Check that the filter returned the correct number of rows cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -252,10 +255,10 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) - checkTableCell({ row: 0, column: 8, expectedContent: '6' }) - checkTableCell({ row: 1, column: 8, expectedContent: '32' }) + checkTableCell({ row: 0, column: 7, expectedContent: '6' }) + checkTableCell({ row: 1, column: 7, expectedContent: '32' }) - // right-click a row: Event layers have no profile to view + // Right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') @@ -266,7 +269,7 @@ describe('data table', () => { 'not.exist' ) - // check that the org unit profile drawer is NOT opened + // Check that the org unit profile drawer is NOT opened cy.getByDataTest('org-unit-profile').should('not.exist') }) @@ -314,7 +317,7 @@ describe('data table', () => { cy.getByDataTest('layers-toggle-button').click() // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -323,15 +326,15 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that first row has Tihun CHC with value 28.63 - checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 0, column: 4, expectedContent: '28.63' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) // Check that row 5 has Gbamgbama CHC with value 117.98 - checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 4, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) - checkTableCell({ row: 6, column: 4, expectedContent: '' }) + checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() @@ -339,24 +342,23 @@ describe('data table', () => { // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 0, column: 4, expectedContent: '117.98' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 4, expectedContent: '28.63' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) - checkTableCell({ row: 6, column: 4, expectedContent: '' }) + checkTableCell({ row: 6, column: 3, expectedContent: '' }) - // Sort by index (a new column, so ascending) - cy.getByDataTest('data-table-column-sort-button-Index').click() + // Third click on the same column cycles back to natural (unsorted) + // order - there's no dedicated Index column/button any more + cy.getByDataTest('data-table-column-sort-button-Value').click() // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 1, expectedContent: '0' }) - // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 6, expectedContent: '' }) + checkTableCell({ row: 0, column: 5, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() @@ -365,12 +367,12 @@ describe('data table', () => { cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 6, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 6, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 6, expectedContent: '' }) + checkTableCell({ row: 6, column: 5, expectedContent: '' }) }) }) diff --git a/i18n/en.pot b/i18n/en.pot index 93fde8b3c..a28648bb6 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-07-13T09:39:24.427Z\n" -"PO-Revision-Date: 2026-07-13T09:39:24.427Z\n" +"POT-Creation-Date: 2026-07-16T11:34:34.211Z\n" +"PO-Revision-Date: 2026-07-16T11:34:34.212Z\n" msgid "2020" msgstr "2020" @@ -167,26 +167,49 @@ 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 "Show only selected features" -msgstr "Show only selected features" - -msgid "Highlight color" -msgstr "Highlight color" - 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" -msgstr "Select all" +msgid "Select all visible rows" +msgstr "Select all visible rows" + +msgid "Reverse selection of visible rows" +msgstr "Reverse selection of visible rows" + +msgid "Sort by Selected" +msgstr "Sort by Selected" msgid "Sort by {{column}}" msgstr "Sort by {{column}}" @@ -194,9 +217,54 @@ msgstr "Sort by {{column}}" msgid "Something went wrong" msgstr "Something went wrong" +msgid "greater than 5" +msgstr "greater than 5" + +msgid "greater than or equal to 5" +msgstr "greater than or equal to 5" + +msgid "less than (or equal to) 5" +msgstr "less than (or equal to) 5" + +msgid "equal to 2 OR greater than 8" +msgstr "equal to 2 OR greater than 8" + +msgid "greater than 3 AND less than 8" +msgstr "greater than 3 AND less than 8" + +msgid "Select values, or type text" +msgstr "Select values, or type text" + +msgid "to match rows that contain it" +msgstr "to match rows that contain it" + +msgid "Use filter" +msgstr "Use filter" + +msgid "Contains" +msgstr "Contains" + +msgid "Search or type > 5, < 8…" +msgstr "Search or type > 5, < 8…" + msgid "Search" msgstr "Search" +msgid "Reverse selection" +msgstr "Reverse selection" + +msgid "Any value" +msgstr "Any value" + +msgid "Too many values to list - type to filter this column" +msgstr "Too many values to list - type to filter this column" + +msgid "No matches" +msgstr "No matches" + +msgid "No value" +msgstr "No value" + msgid "Drill up one level" msgstr "Drill up one level" @@ -215,6 +283,9 @@ msgstr "Zoom to layer" msgid "Zoom to selected features" msgstr "Zoom to selected features" +msgid "Zoom to filtered features" +msgstr "Zoom to filtered features" + 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." @@ -231,9 +302,6 @@ msgstr "" msgid "No valid data fields were found for this layer." msgstr "No valid data fields were found for this layer." -msgid "Index" -msgstr "Index" - msgid "Id" msgstr "Id" @@ -261,6 +329,12 @@ msgstr "Org unit boundary" msgid "Event time" msgstr "Event time" +msgid "Loading Earth Engine data…" +msgstr "Loading Earth Engine data…" + +msgid "Loading additional events…" +msgstr "Loading additional events…" + msgid "Items" msgstr "Items" @@ -869,9 +943,6 @@ msgstr "" msgid "no value" msgstr "no value" -msgid "All" -msgstr "All" - msgid "Loading data" msgstr "Loading data" @@ -1729,6 +1800,9 @@ msgstr "16-day" msgid "Since February 2000" msgstr "Since February 2000" +msgid "Index" +msgstr "Index" + msgid "NDVI" msgstr "NDVI" diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 133e680b7..5b9adde54 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -23,12 +23,8 @@ export const toggleShowOnlyFeaturesInView = () => ({ type: types.TOGGLE_SHOW_ONLY_IN_VIEW, }) -export const toggleShowOnlySelected = () => ({ - type: types.TOGGLE_SHOW_ONLY_SELECTED, -}) - -export const setShowOnlySelected = (value) => ({ - type: types.SHOW_ONLY_SELECTED_SET, +export const setSelectionFilter = (value) => ({ + type: types.SELECTION_FILTER_SET, value, }) diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index 78de9d4a2..b46a6354f 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,59 +49,6 @@ export const IconZoomIn16 = () => ( ) -// Two stacked chevrons — "collapse"/"restore to full height" toggle. -export const IconChevronDoubleDown16 = () => ( - - - - -) - -export const IconChevronDoubleUp16 = () => ( - - - - -) - export const IconDrag = () => ( { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) @@ -48,15 +46,11 @@ const BottomPanel = () => { const activeLayer = useSelector((state) => state.map.mapViews.find((l) => l.id === activeLayerId) ) - const dataFilters = activeLayer?.dataFilters ?? {} - const hasActiveFilters = Object.keys(dataFilters).length > 0 + const dataFilters = activeLayer?.dataFilters ?? EMPTY_FILTERS const showOnlyFeaturesInView = useSelector( (state) => state.ui.showOnlyFeaturesInView ) - const showOnlySelected = useSelector((state) => state.ui.showOnlySelected) - const selection = useSelector((state) => state.selection) - const selectedCount = - selection.layerId === activeLayerId ? selection.ids.length : 0 + const selectionFilter = useSelector((state) => state.ui.selectionFilter) const highlightColor = useSelector((state) => state.ui.highlightColor) const dispatch = useDispatch() @@ -69,6 +63,13 @@ const BottomPanel = () => { const [filteredCount, setFilteredCount] = useState(null) const [nameTooltipPos, setNameTooltipPos] = useState(null) const [isCollapsed, setIsCollapsed] = useState(false) + const [globalSearch, setGlobalSearch] = useState('') + + const hasActiveFilters = + Object.keys(dataFilters).length > 0 || + globalSearch.trim() !== '' || + selectionFilter?.length > 0 || + showOnlyFeaturesInView const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') @@ -111,6 +112,15 @@ const BottomPanel = () => { setFilteredCount(filtered) }, []) + const onClearFilters = useCallback(() => { + dispatch(clearDataFilters(activeLayerId)) + dispatch(setSelectionFilter([])) + setGlobalSearch('') + if (showOnlyFeaturesInView) { + dispatch(toggleShowOnlyFeaturesInView()) + } + }, [dispatch, activeLayerId, showOnlyFeaturesInView]) + const onNameMouseEnter = useCallback(() => { const el = nameRef.current if (!el || el.scrollWidth <= el.offsetWidth) { @@ -163,12 +173,6 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) - useEffect(() => { - if (showOnlySelected && selectedCount === 0) { - dispatch(setShowOnlySelected(false)) - } - }, [dispatch, showOnlySelected, selectedCount]) - let rowCountLabel = null if (totalCount !== null && filteredCount !== null) { rowCountLabel = @@ -199,14 +203,16 @@ const BottomPanel = () => { content={ isCollapsed ? i18n.t('Restore') : i18n.t('Collapse') } + placement="top" > {isCollapsed ? ( - + ) : ( - + )} + { , document.body )} + + + + + dispatch(setHighlightColor(color)) + } + /> + + { {rowCountLabel && ( {rowCountLabel} )} - {hasActiveFilters && ( - - )} + + +
e.stopPropagation()} + > + setGlobalSearch(value)} + /> +
- - - dispatch(setHighlightColor(color))} - /> - + @@ -310,7 +326,8 @@ const BottomPanel = () => { diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7fd1f0ead..7e05c4b3f 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -9,7 +9,9 @@ import { ComponentCover, CenteredContent, CircularLoader, - Tooltip, + Popper, + Portal, + IconSync16, } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' @@ -23,28 +25,172 @@ import React, { } from 'react' import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' +import { setSelectionFilter } from '../../actions/dataTable.js' import { highlightFeature } from '../../actions/feature.js' import { toggleFeatureSelection, - selectAllFeatures, selectFeatureRange, - clearSelection, } from '../../actions/selection.js' +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 { formatWithSeparator } from '../../util/numbers.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 styles from './styles/DataTable.module.css' import TableContextMenu from './TableContextMenu.jsx' +import { useColumnWidths } from './useColumnWidths.js' +import { useRowSelection } from './useRowSelection.js' import { useTableData } from './useTableData.js' -const ASCENDING = 'asc' -const DESCENDING = 'desc' +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 @@ -115,31 +261,60 @@ DataTableRowWithVirtuosoContext.propTypes = { ), } -const TableComponents = { - Table: DataTableWithVirtuosoContext, - TableBody: DataTableBody, - TableHead: DataTableHead, - TableRow: DataTableRowWithVirtuosoContext, - EmptyPlaceholder: () => ( +const EmptyPlaceholder = ({ context }) => ( +
- {i18n.t('No results found')} + {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, showOnlySelected }) => { +const Table = ({ + availableWidth, + onCountChange, + globalSearch, + onClearFilters, +}) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const headerRowRef = useRef(null) const virtuosoRef = useRef(null) - const [columnWidths, setColumnWidths] = useState([]) - const minColumnWidthsRef = useRef([]) const { mapViews } = useSelector((state) => state.map) const activeLayerId = useSelector((state) => state.dataTable) @@ -150,11 +325,12 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { (state) => state.ui.showOnlyFeaturesInView ) 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: ASCENDING, + sortDirection: SORT_ASCENDING, } ) @@ -162,13 +338,7 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { const sortData = useCallback( ({ name }) => { - setSorting({ - sortField: name, - sortDirection: - name === sortField && sortDirection === ASCENDING - ? DESCENDING - : ASCENDING, - }) + setSorting(getNextSorting(name, { sortField, sortDirection })) }, [sortField, sortDirection] ) @@ -235,16 +405,31 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { ) const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) - const { headers, rows, isLoading, error, totalCount, filteredCount } = - useTableData({ - layer, - sortField, - sortDirection, - showOnlyFeaturesInView, - mapBounds, - showOnlySelected, - selectedIdSet, - }) + const { + headers, + rows, + isLoading, + loadingReason, + error, + totalCount, + filteredCount, + columnOptions, + } = useTableData({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + selectionFilter, + selectedIdSet, + globalSearch, + }) + + const { headerRowRef, columnWidths } = useColumnWidths({ + availableWidth, + headers, + error, + }) useEffect(() => { onCountChange?.(totalCount, filteredCount) @@ -302,6 +487,11 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { [dispatch, layer.id] ) + const hasActiveFilters = + Object.keys(layer.dataFilters ?? {}).length > 0 || + !!globalSearch?.trim() || + selectionFilter?.length > 0 + const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, @@ -310,6 +500,9 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { onRowClick, onRowDoubleClick, layout: columnWidths.length > 0 ? 'fixed' : 'auto', + totalCount, + hasActiveFilters, + onClearFilters, }), [ setFeatureHighlight, @@ -318,6 +511,9 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { onRowClick, onRowDoubleClick, columnWidths, + totalCount, + hasActiveFilters, + onClearFilters, ] ) @@ -350,83 +546,14 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { () => rows?.map(getRowId).filter(Boolean) ?? [], [rows] ) - const allRowIdSet = useMemo(() => new Set(allRowIds), [allRowIds]) - - const isAllSelected = useMemo( - () => - allRowIds.length > 0 && - allRowIds.every((id) => selectedIdSet.has(id)), - [allRowIds, selectedIdSet] - ) - - const onToggleSelectAll = useCallback(() => { - const nextIds = isAllSelected - ? selectedIds.filter((id) => !allRowIdSet.has(id)) - : [...new Set([...selectedIds, ...allRowIds])] - - if (nextIds.length) { - dispatch(selectAllFeatures(nextIds, layer.id)) - } else { - dispatch(clearSelection()) - } - }, [dispatch, isAllSelected, allRowIds, allRowIdSet, selectedIds, layer.id]) - useEffect(() => { - // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling - if (columnWidths.length === 0 && headerRowRef.current) { - const frameId = requestAnimationFrame(() => { - if (!headerRowRef.current) { - return - } - - const measuredColumnWidths = [] - - const dataCells = Array.from(headerRowRef.current.cells).slice( - 1 - ) - - for (const cell of dataCells) { - const rect = cell.getBoundingClientRect() - measuredColumnWidths.push(Math.floor(rect.width)) - } - - minColumnWidthsRef.current = measuredColumnWidths - setColumnWidths(measuredColumnWidths) - }) - - return () => cancelAnimationFrame(frameId) - } - }, [columnWidths]) - - useEffect(() => { - // Reset to auto layout for re-measurement when headers change - if (!error) { - minColumnWidthsRef.current = [] - setColumnWidths([]) - } - }, [headers, error]) - - useEffect(() => { - // Scale column widths proportionally on resize, clamped to initial measured widths - if (!error) { - setColumnWidths((prev) => { - if (prev.length === 0) { - return prev - } - const prevTotal = prev.reduce((sum, w) => sum + w, 0) - if (prevTotal === 0 || availableWidth === 0) { - return [] - } - const minWidths = minColumnWidthsRef.current - return prev.map((w, i) => - Math.max( - minWidths[i] ?? 0, - Math.round((w / prevTotal) * availableWidth) - ) - ) - }) - } - }, [availableWidth, error]) + const { isAllSelected, onToggleSelectAll, onReverseSelection } = + useRowSelection({ + selectedIds, + selectedIdSet, + allRowIds, + layerId: layer.id, + }) if (error) { return

{error}

@@ -444,68 +571,135 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { }} data={rows} computeItemKey={(index, row) => getRowId(row) ?? index} + increaseViewportBy={{ top: 400, bottom: 400 }} fixedHeaderContent={() => ( + dispatch(setSelectionFilter(next)) + } + /> + } > - - - {headers.map(({ name, dataKey, type }, index) => ( - - ) - } - width={ - columnWidths.length > 0 - ? `${columnWidths[index]}px` - : 'auto' - } - > - - {name} - + + + + + + + + + + + + {headers.map( + ({ name, dataKey, type, optionSet }, index) => ( + + ) + } + width={ + columnWidths.length > 0 + ? `${columnWidths[index]}px` + : 'auto' + } + > + + {name} + - + sortData({ + name: dataKey, + }) } - /> - - - - - ))} + > + + + +
+ + ) + )} )} itemContent={(_, row) => { @@ -548,8 +742,13 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { [styles.lightText]: dataKey === 'color' && isDarkColor(value), - [styles.selected]: isSelected, - [styles.hovered]: isHovered, + [styles.monoCell]: + dataKey === 'id' || + dataKey === 'color', + [styles.selected]: + isSelected && dataKey !== 'color', + [styles.hovered]: + isHovered && dataKey !== 'color', })} backgroundColor={ dataKey === 'color' ? value : null @@ -571,7 +770,14 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( - +
+ + {loadingReason && ( + + {loadingReason} + + )} +
)} @@ -579,6 +785,7 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { contextMenu={tableContextMenu} layer={layer} selectedIds={selectedIds} + filteredIds={hasActiveFilters ? allRowIds : null} onClose={() => setTableContextMenu(null)} /> @@ -587,7 +794,8 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { Table.propTypes = { availableWidth: PropTypes.number, - showOnlySelected: PropTypes.bool, + globalSearch: PropTypes.string, + onClearFilters: PropTypes.func, onCountChange: PropTypes.func, } diff --git a/src/components/datatable/FilterDropdownPopover.jsx b/src/components/datatable/FilterDropdownPopover.jsx new file mode 100644 index 000000000..ae2f257c0 --- /dev/null +++ b/src/components/datatable/FilterDropdownPopover.jsx @@ -0,0 +1,50 @@ +import { Layer, Popper } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' + +const ESTIMATED_POPOVER_HEIGHT = 340 // Rough popover height used to flip the dropdown when there isn't room to open downward + +const dropdownModifiers = [ + { name: 'offset', options: { offset: [0, 0] } }, + { name: 'flip', enabled: false }, +] + +export const getDropdownPlacement = (anchorRect) => { + const dropdownSide = + anchorRect != null && + window.innerHeight - anchorRect.bottom < ESTIMATED_POPOVER_HEIGHT + ? 'top' + : 'bottom' + return { + dropdownSide, + dropdownPlacement: `${dropdownSide}-start`, + tooltipPlacement: dropdownSide === 'top' ? 'bottom' : 'top', + } +} + +export const FilterDropdownPopover = ({ + reference, + placement, + onClickOutside, + className, + children, +}) => ( + + + {children} + + +) + +FilterDropdownPopover.propTypes = { + children: PropTypes.node.isRequired, + placement: PropTypes.oneOf(['top-start', 'bottom-start']).isRequired, + reference: PropTypes.object.isRequired, + onClickOutside: PropTypes.func.isRequired, + className: PropTypes.string, +} diff --git a/src/components/datatable/FilterInput.jsx b/src/components/datatable/FilterInput.jsx index 16bab4a9c..ee2fd4515 100644 --- a/src/components/datatable/FilterInput.jsx +++ b/src/components/datatable/FilterInput.jsx @@ -1,17 +1,604 @@ import i18n from '@dhis2/d2-i18n' -import { Input } from '@dhis2/ui' +import { Input, Popper, Portal, IconFilter16, IconSync16 } from '@dhis2/ui' +import cx from 'classnames' import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect, 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, +} from '../../constants/dataTable.js' +import useOptionSet from '../../hooks/useOptionSet.js' +import { numericFilter } from '../../util/filter.js' +import { + getInvertibleValues, + reverseSelection, + toggleAnyValue, + toggleRealValue, +} from '../../util/filterSelection.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import Checkbox from '../core/Checkbox.jsx' +import { + FilterDropdownPopover, + getDropdownPlacement, +} from './FilterDropdownPopover.jsx' +import styles from './styles/FilterInput.module.css' -const FilterInput = ({ type, dataKey, name }) => { +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 = ( +
+
{i18n.t('Select values, or type a numerical filter:')}
+
{'> 5 — ' + i18n.t('greater than 5')}
+
{'>= 5 — ' + i18n.t('greater than or equal to 5')}
+
{'< 5, <= 5 — ' + i18n.t('less than (or equal to) 5')}
+
{'2, > 8 — ' + i18n.t('equal to 2 OR greater than 8')}
+
{'> 3 & < 8 — ' + i18n.t('greater than 3 AND less than 8')}
+
+) +const TEXT_FILTER_HELP = ( +
+
{i18n.t('Select values, or type text')}
+
{i18n.t('to match rows that contain it')}
+
+) +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, + layerId, + filterValue, + options, + resolveLabel, + type, + allowCustomFilter = true, +}) => { const dispatch = useDispatch() + const anchorRef = useRef(null) + const listRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [searchText, setSearchText] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + const { selected, appliedString } = getSelectedAndAppliedString(filterValue) + + const openPopover = () => { + setSearchText(appliedString) + setHighlightedIndex(-1) + setIsOpen(true) + } + + const closePopover = () => setIsOpen(false) + + const anchorRect = anchorRef.current?.getBoundingClientRect() + const anchorWidth = anchorRect?.width + const { dropdownPlacement, dropdownSide, tooltipPlacement } = + getDropdownPlacement(anchorRect) + + const applyValues = (next) => + next.length + ? dispatch(setDataFilter(layerId, dataKey, next)) + : dispatch(clearDataFilter(layerId, dataKey)) + + const toggleValue = (value) => { + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + applyValues(next) + } + + const applyCustomFilter = (text) => + text + ? dispatch(setDataFilter(layerId, dataKey, text)) + : dispatch(clearDataFilter(layerId, dataKey)) + + const hasNotSetOption = options.some( + ({ value }) => value === SENTINEL_NO_VALUE + ) + const realOptions = options.filter( + ({ value }) => value !== SENTINEL_NO_VALUE + ) + const realValues = realOptions.map((o) => o.value) + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + + const onToggleAnyValue = () => applyValues(toggleAnyValue(selected)) + + const invertibleValues = getInvertibleValues(hasNotSetOption, realValues) + + const onToggleRealValue = (value) => + applyValues(toggleRealValue(selected, value, realValues)) + + const onReverseSelection = () => + applyValues(reverseSelection(selected, realValues, hasNotSetOption)) + + const trimmedSearch = searchText.trim() + const normalizedSearch = trimmedSearch.toLowerCase() + const filteredOptions = getFilteredOptions({ + realOptions, + trimmedSearch, + normalizedSearch, + type, + resolveLabel, + }) + const hasExactMatch = filteredOptions.some( + ({ value }) => resolveLabel(value).toLowerCase() === normalizedSearch + ) + const showCustomFilterRow = + allowCustomFilter && normalizedSearch !== '' && !hasExactMatch + const totalCount = filteredOptions.length + (showCustomFilterRow ? 1 : 0) + + const customFilterTag = + type === 'number' ? i18n.t('Use filter') : i18n.t('Contains') + + const hasActiveFilter = selected.length > 0 || appliedString !== '' + + const onSearchChange = ({ value }) => { + const sanitized = + type === 'number' + ? value.replace(NUMERIC_INPUT_DISALLOWED, '') + : value + setSearchText(sanitized) + setHighlightedIndex(-1) + + const trimmed = sanitized.trim() + if (trimmed === '') { + if (hasActiveFilter) { + dispatch(clearDataFilter(layerId, dataKey)) + } + return + } + + if (!allowCustomFilter) { + return + } + + const normalized = trimmed.toLowerCase() + const exactMatch = options.some( + ({ value: optionValue }) => + resolveLabel(optionValue).toLowerCase() === normalized + ) + if (!exactMatch) { + applyCustomFilter(trimmed) + } + } + + const scrollHighlightedIntoView = (index) => { + const optionIndex = showCustomFilterRow ? index - 1 : index + if (optionIndex >= 0 && optionIndex < filteredOptions.length) { + listRef.current?.scrollToIndex({ + index: optionIndex, + align: 'center', + }) + } + } + + const onEnterKey = () => { + if (highlightedIndex === -1) { + if (showCustomFilterRow) { + applyCustomFilter(searchText.trim()) + } + return + } + if (showCustomFilterRow && highlightedIndex === 0) { + applyCustomFilter(searchText.trim()) + return + } + const optionIndex = showCustomFilterRow + ? highlightedIndex - 1 + : highlightedIndex + if (optionIndex >= 0 && optionIndex < filteredOptions.length) { + toggleValue(filteredOptions[optionIndex].value) + } + } + + const onSearchKeyDown = (_, event) => { + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + setHighlightedIndex((i) => { + const next = totalCount ? (i + 1) % totalCount : -1 + scrollHighlightedIntoView(next) + return next + }) + break + case 'ArrowUp': + event.preventDefault() + setHighlightedIndex((i) => { + const next = totalCount + ? (i - 1 + totalCount) % totalCount + : -1 + scrollHighlightedIntoView(next) + return next + }) + break + case 'Enter': + event.preventDefault() + onEnterKey() + closePopover() + break + case 'Escape': + event.preventDefault() + closePopover() + break + default: + break + } + } + + const displayValue = getDisplayValue({ + isOpen, + searchText, + selected, + appliedString, + }) + + const mainInput = ( + 5, < 8…') + : i18n.t('Search') + } + value={displayValue} + onFocus={() => { + if (!isOpen) { + openPopover() + } + }} + onChange={onSearchChange} + onKeyDown={onSearchKeyDown} + /> + ) + + return ( +
+ + {mainInput} + + {isOpen && ( + +
+ {showCustomFilterRow && ( + + )} +
+ + + {hasNotSetOption && ( + + toggleValue(SENTINEL_NO_VALUE) + } + className={styles.specialOption} + style={{ margin: '4px 0' }} + dataTest={`data-table-column-filter-novalue-${name}`} + /> + )} +
+
+ {!showCustomFilterRow && + (options.length === 0 ? ( +
+ {i18n.t( + 'Too many values to list - type to filter this column' + )} +
+ ) : ( + filteredOptions.length === 0 && ( +
+ {i18n.t('No matches')} +
+ ) + ))} + {filteredOptions.length > 0 && ( + option.value} + itemContent={(index, option) => ( + + onToggleRealValue(option.value) + } + className={cx( + (dataKey === 'id' || + dataKey === 'color') && + styles.monoOption, + highlightedIndex === + (showCustomFilterRow + ? index + 1 + : index) && + styles.highlighted + )} + style={{ margin: '4px 0' }} + /> + )} + /> + )} +
+
+
+ )} +
+ ) +} + +SearchableFilterPopover.propTypes = { + dataKey: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })) + .isRequired, + resolveLabel: PropTypes.func.isRequired, + type: PropTypes.string.isRequired, + allowCustomFilter: PropTypes.bool, + filterValue: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.arrayOf(PropTypes.string), + ]), + layerId: PropTypes.string, +} + +const PlainSearchableFilter = (props) => { + const { type } = props + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + + const resolveLabel = (value) => { + if (value === SENTINEL_NO_VALUE) { + return i18n.t('No value') + } + return type === 'number' + ? formatWithSeparator(Number(value), keyAnalysisDigitGroupSeparator) + : value + } + + return +} + +PlainSearchableFilter.propTypes = { + type: PropTypes.string, +} + +const OptionSetSearchableFilter = ({ optionSetId, ...props }) => { + const { optionSet } = useOptionSet(optionSetId) + const resolveLabel = (value) => + value === SENTINEL_NO_VALUE + ? i18n.t('No value') + : optionSet?.options.find((o) => o.code === value)?.name ?? value + return ( + + ) +} + +OptionSetSearchableFilter.propTypes = { + optionSetId: PropTypes.string.isRequired, +} + +const FilterInput = ({ type, dataKey, name, options, optionSetId }) => { const dataTable = useSelector((state) => state.dataTable) const map = useSelector((state) => state.map) const overlay = - dataTable && map.mapViews.filter((layer) => layer.id === dataTable)[0] + dataTable && map.mapViews.find((layer) => layer.id === dataTable) let layerId let filters @@ -20,20 +607,26 @@ const FilterInput = ({ type, dataKey, name }) => { filters = overlay.dataFilters || {} } - const filterValue = filters[dataKey] || '' + const filterValue = filters?.[dataKey] - const onChange = ({ value }) => - value !== '' - ? dispatch(setDataFilter(layerId, dataKey, value)) - : dispatch(clearDataFilter(layerId, dataKey, value)) - - return ( - 3&<8' : i18n.t('Search')} - value={filterValue} - onChange={onChange} + return optionSetId ? ( + + ) : ( + ) } @@ -42,6 +635,8 @@ FilterInput.propTypes = { dataKey: PropTypes.string.isRequired, name: PropTypes.string.isRequired, type: PropTypes.string.isRequired, + optionSetId: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ value: PropTypes.string })), } export default FilterInput diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx index 13985be16..50711045f 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/ResizeHandle.jsx @@ -1,15 +1,8 @@ import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect, useRef } from 'react' import { IconDrag } from '../core/icons.jsx' import styles from './styles/ResizeHandle.module.css' -// Pre-decoded so setDragImage doesn't fall back to the macOS Chrome globe icon -// when the image isn't yet `complete` on the dragstart tick. -// https://www.sam.today/blog/html5-dnd-globe-icon -const EMPTY_DRAG_IMAGE = new Image(1, 1) -EMPTY_DRAG_IMAGE.src = - 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' - const ResizeHandle = ({ onResize, onResizeStart, @@ -17,63 +10,65 @@ const ResizeHandle = ({ minHeight = 50, maxHeight = 500, }) => { - let dragHeight = 0 - - const onDragStart = (evt) => { - // https://stackoverflow.com/questions/7680285/how-do-you-turn-off-setdragimage - if (EMPTY_DRAG_IMAGE.complete) { - evt.dataTransfer.setDragImage(EMPTY_DRAG_IMAGE, 0, 0) - } + const isDraggingRef = useRef(false) + + const getHeight = (clientY) => { + const height = window.innerHeight - clientY + return height < minHeight + ? minHeight + : height > maxHeight + ? maxHeight + : height + } - evt.dataTransfer.setData('text/plain', 'node') // Required to initialize dragging in Firefox + const onPointerDown = (evt) => { + evt.preventDefault() // avoid text selection while dragging + evt.currentTarget.setPointerCapture(evt.pointerId) + isDraggingRef.current = true onResizeStart?.() - - // https://stackoverflow.com/questions/23992091/drag-and-drop-directive-no-e-clientx-or-e-clienty-on-drag-event-in-firefox - document.ondragover = onDrag + evt.currentTarget.style.cursor = 'grabbing' + document.body.style.cursor = 'grabbing' } - const onDrag = (evt) => { - const height = getHeight(evt || window.event) - - if (height && onResize) { - onResize(height) - dragHeight = height + const onPointerMove = (evt) => { + if (isDraggingRef.current) { + onResize?.(getHeight(evt.clientY)) } } - const onDragEnd = (evt) => { - const height = getHeight(evt) - - if (height && onResizeEnd) { - onResizeEnd(height) + const onPointerUp = (evt) => { + if (!isDraggingRef.current) { + return } - - document.ondragover = null + isDraggingRef.current = false + evt.currentTarget.releasePointerCapture(evt.pointerId) + evt.currentTarget.style.removeProperty('cursor') + document.body.style.removeProperty('cursor') + onResizeEnd?.(getHeight(evt.clientY)) } - const getHeight = (evt) => { - if (evt.pageY) { - const height = window.innerHeight - evt.pageY - dragHeight = - height < minHeight - ? minHeight - : height > maxHeight - ? maxHeight - : height - } - - return dragHeight - } + // In case the handle/panel unmounts mid-drag + useEffect( + () => () => { + if (isDraggingRef.current) { + document.body.style.removeProperty('cursor') + } + }, + [] + ) return (
onDragStart(evt)} - onDragEnd={(evt) => onDragEnd(evt)} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp} > - + + +
) } diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index 21f582693..add98bcb6 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -24,7 +24,13 @@ import { drillUpDown } from '../../util/map.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { IconZoomIn16 } from '../core/icons.jsx' -const TableContextMenu = ({ contextMenu, layer, selectedIds, onClose }) => { +const TableContextMenu = ({ + contextMenu, + layer, + selectedIds, + filteredIds, + onClose, +}) => { const anchorRef = useRef() const dispatch = useDispatch() const { @@ -191,6 +197,23 @@ const TableContextMenu = ({ contextMenu, layer, selectedIds, onClose }) => { onClose() }} /> + } + disabled={!filteredIds?.length} + onClick={() => { + dispatch( + highlightFeature({ + ids: filteredIds, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> @@ -205,6 +228,7 @@ TableContextMenu.propTypes = { x: PropTypes.number, y: PropTypes.number, }), + filteredIds: PropTypes.array, selectedIds: PropTypes.array, } diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index e236e5183..28dc4de68 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -1,18 +1,19 @@ import { shouldClearFeatureHighlight, getRowClickAction, + getNextSorting, + isFilterable, } from '../DataTable.jsx' +import { getReversedSelection } from '../useRowSelection.js' -// DataTable.jsx transitively imports MapApi.js (maplibre-gl), which is not -// needed here and fails to load under jsdom. +// DataTable.jsx transitively imports MapApi.js (maplibre-gl), +// which is not needed here and fails to load under jsdom. jest.mock('../../map/MapApi.js', () => ({ loadEarthEngineWorker: jest.fn(), })) describe('shouldClearFeatureHighlight', () => { test('clears when leaving to no element (cursor exits the window)', () => { - // Regression: relatedTarget is null on window exit, which previously - // threw `null.tagName` and crashed the table via the ErrorBoundary. expect(shouldClearFeatureHighlight({ relatedTarget: null })).toBe(true) }) @@ -82,3 +83,70 @@ describe('getRowClickAction', () => { ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] }) }) }) + +describe('getNextSorting', () => { + test('clicking an unsorted column starts at ascending', () => { + expect( + getNextSorting('name', { sortField: null, sortDirection: 'asc' }) + ).toEqual({ sortField: 'name', sortDirection: 'asc' }) + }) + + test('clicking the ascending-sorted column moves to descending', () => { + expect( + getNextSorting('name', { sortField: 'name', sortDirection: 'asc' }) + ).toEqual({ sortField: 'name', sortDirection: 'desc' }) + }) + + test('clicking the descending-sorted column clears back to natural order', () => { + expect( + getNextSorting('name', { sortField: 'name', sortDirection: 'desc' }) + ).toEqual({ sortField: null, sortDirection: 'asc' }) + }) + + test('clicking a different column restarts the cycle at ascending', () => { + expect( + getNextSorting('type', { sortField: 'name', sortDirection: 'desc' }) + ).toEqual({ sortField: 'type', sortDirection: 'asc' }) + }) +}) + +describe('isFilterable', () => { + test('allows numeric and string columns', () => { + expect(isFilterable('rawValue', 'number')).toBe(true) + expect(isFilterable('name', 'string')).toBe(true) + }) + + test('excludes columns with no type (no known filter UI for them)', () => { + expect(isFilterable('someKey', undefined)).toBe(false) + }) +}) + +describe('getReversedSelection', () => { + test('selects every visible row when nothing is currently selected', () => { + expect(getReversedSelection([], ['a', 'b', 'c'])).toEqual([ + 'a', + 'b', + 'c', + ]) + }) + + test('deselects every visible row when all are currently selected', () => { + expect(getReversedSelection(['a', 'b', 'c'], ['a', 'b', 'c'])).toEqual( + [] + ) + }) + + test('flips only the visible rows, keeping the rest of the selection as-is', () => { + expect(getReversedSelection(['a'], ['a', 'b', 'c'])).toEqual(['b', 'c']) + }) + + test('preserves ids selected outside the current filtered view untouched', () => { + // "z" was selected before a column filter narrowed the visible rows + // down to just a/b/c - reversing must not drop it. + expect(getReversedSelection(['a', 'z'], ['a', 'b', 'c'])).toEqual([ + 'z', + 'b', + 'c', + ]) + }) +}) diff --git a/src/components/datatable/__tests__/FilterInput.spec.jsx b/src/components/datatable/__tests__/FilterInput.spec.jsx new file mode 100644 index 000000000..ea143ce67 --- /dev/null +++ b/src/components/datatable/__tests__/FilterInput.spec.jsx @@ -0,0 +1,887 @@ +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 } from '../../../constants/dataTable.js' +import useOptionSet from '../../../hooks/useOptionSet.js' +import FilterInput from '../FilterInput.jsx' + +jest.mock('../../../hooks/useOptionSet.js', () => ({ + __esModule: true, + default: jest.fn(), +})) + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'COMMA' }, + }), +})) + +const mockStore = configureMockStore() + +const renderFilterInput = (props, dataFilters) => { + const store = mockStore({ + dataTable: 'layer1', + map: { + mapViews: [{ id: 'layer1', dataFilters: dataFilters || {} }], + }, + }) + // The checkbox list is virtualized (react-virtuoso) + const result = render( + + + + + + ) + return { ...result, store } +} + +// The trigger and the dropdown's search field are the same +const getInput = (name) => + screen + .getByTestId(`data-table-column-filter-search-${name}`) + .querySelector('input') + +const openPopover = (name) => fireEvent.focus(getInput(name)) + +describe('FilterInput with no known options (over the cap, or not yet loaded)', () => { + test('shows an empty input with a "Search" placeholder by default', () => { + renderFilterInput({}) + const input = getInput('Name') + expect(input).toHaveValue('') + expect(input).toHaveAttribute('placeholder', 'Search') + }) + + test('explains there is no picklist instead of showing an empty popover', () => { + renderFilterInput({}) + openPopover('Name') + expect( + screen.getByText( + 'Too many values to list - type to filter this column' + ) + ).toBeInTheDocument() + }) + + test('hides that hint once the user starts typing', () => { + renderFilterInput({}) + openPopover('Name') + fireEvent.change(getInput('Name'), { + target: { value: 'hospital' }, + }) + expect( + screen.queryByText( + 'Too many values to list - type to filter this column' + ) + ).not.toBeInTheDocument() + }) + + test('shows the current filter value on the (closed) trigger', () => { + renderFilterInput({}, { name: 'hospital' }) + expect(getInput('Name')).toHaveValue('hospital') + }) + + test('applies a custom filter live, as soon as it no longer matches an option', () => { + const { store } = renderFilterInput({}) + openPopover('Name') + fireEvent.change(getInput('Name'), { + target: { value: 'hospital' }, + }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'name', + filter: 'hospital', + }) + }) + + test('wraps the search input in a numeric syntax help tooltip for number columns', () => { + renderFilterInput({ type: 'number' }) + expect( + screen.getByTestId('data-table-filter-help-reference') + ).toBeInTheDocument() + }) + + test('also wraps the search input in a help tooltip for string columns', () => { + renderFilterInput({ type: 'string' }) + expect( + screen.getByTestId('data-table-filter-help-reference') + ).toBeInTheDocument() + }) +}) + +describe('FilterInput multi-select path (no optionSetId)', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('shows an empty input when nothing is selected', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + expect(getInput('Legend')).toHaveValue('') + }) + + test('shows the selected count on the closed trigger', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + expect(getInput('Legend')).toHaveValue('1 selected') + }) + + test('opens a popover with a checkbox per option, using the raw value as the label', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + expect(screen.getByLabelText('High')).toBeInTheDocument() + expect(screen.getByLabelText('Low')).toBeInTheDocument() + }) + + test('shows a "No value" label for the blank-value sentinel option', () => { + renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }) + openPopover('Parent') + expect(screen.getByLabelText('No value')).toBeInTheDocument() + expect(screen.getByLabelText('Country')).toBeInTheDocument() + }) + + test('"No value" is grouped with "Any value" above the divider, not mixed into the searchable list', () => { + renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }) + openPopover('Parent') + expect( + screen.getByLabelText('No value').closest('.pinnedOptions') + ).toBeInTheDocument() + expect( + screen.getByLabelText('Any value').closest('.pinnedOptions') + ).toBeInTheDocument() + }) + + test('"No value" stays visible while searching, unlike the narrowed checkbox list', () => { + renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }) + openPopover('Parent') + fireEvent.change(getInput('Parent'), { target: { value: 'zzz' } }) + expect(screen.getByLabelText('No value')).toBeInTheDocument() + expect(screen.queryByLabelText('Country')).not.toBeInTheDocument() + }) + + test('omits "No value" entirely when the column has no blank cells', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + expect(screen.queryByLabelText('No value')).not.toBeInTheDocument() + }) + + test('renders Id column options in monospace', () => { + renderFilterInput({ + dataKey: 'id', + name: 'Id', + options: [{ value: 'abc123' }], + }) + openPopover('Id') + expect( + screen.getByLabelText('abc123').closest('.monoOption') + ).toBeInTheDocument() + }) + + test('formats numeric column options with the system digit group separator', () => { + renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '1234567' }], + }) + openPopover('Value') + expect(screen.getByLabelText('1,234,567')).toBeInTheDocument() + }) + + test('does not format non-numeric column options', () => { + renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options: [{ value: '1000' }], + }) + openPopover('Legend') + expect(screen.getByLabelText('1000')).toBeInTheDocument() + }) +}) + +describe('FilterInput multi-select path (optionSetId)', () => { + const options = [{ value: 'CONFIRMED' }, { value: 'PROBABLE' }] + + beforeEach(() => { + useOptionSet.mockReturnValue({ + optionSet: { + options: [ + { code: 'CONFIRMED', name: 'Confirmed case' }, + { code: 'PROBABLE', name: 'Probable case' }, + ], + }, + }) + }) + + test('resolves stored codes to display names in the popover', () => { + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument() + expect(screen.getByLabelText('Probable case')).toBeInTheDocument() + }) + + test('falls back to the raw code when the option set has not loaded yet', () => { + useOptionSet.mockReturnValue({ optionSet: null }) + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + expect(screen.getByLabelText('CONFIRMED')).toBeInTheDocument() + }) + + test('search narrows the list by the resolved label, not the raw code', () => { + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'confirmed' }, + }) + expect(screen.getByLabelText('Confirmed case')).toBeInTheDocument() + expect(screen.queryByLabelText('Probable case')).not.toBeInTheDocument() + }) + + test('never shows a custom-filter row, even for non-matching text', () => { + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'no such option anywhere' }, + }) + expect( + screen.queryByTestId( + 'data-table-column-filter-custom-Case classification' + ) + ).not.toBeInTheDocument() + }) + + test('typing never dispatches a filter (custom filter disabled)', () => { + const { store } = renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options, + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'no such option anywhere' }, + }) + expect(store.getActions()).toEqual([]) + }) +}) + +describe('FilterInput searchable popover — search', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('narrows the checkbox list to matching labels', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { + target: { value: 'hi' }, + }) + expect(screen.getByLabelText('High')).toBeInTheDocument() + expect(screen.queryByLabelText('Low')).not.toBeInTheDocument() + }) + + test("narrows a numeric column's checkbox list using the typed filter expression, not substring match", () => { + renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '10' }, { value: '150' }, { value: '200' }], + }) + openPopover('Value') + fireEvent.change(getInput('Value'), { + target: { value: '< 100' }, + }) + expect(screen.getByLabelText('10')).toBeInTheDocument() + expect(screen.queryByLabelText('150')).not.toBeInTheDocument() + expect(screen.queryByLabelText('200')).not.toBeInTheDocument() + }) + + test('shows a "no matches" message when nothing matches and no custom filter applies', () => { + useOptionSet.mockReturnValue({ + optionSet: { options: [{ code: 'CONFIRMED', name: 'Confirmed' }] }, + }) + renderFilterInput({ + dataKey: 'caseType', + name: 'Case classification', + options: [{ value: 'CONFIRMED' }], + optionSetId: 'optionSet1', + }) + openPopover('Case classification') + fireEvent.change(getInput('Case classification'), { + target: { value: 'zzz' }, + }) + expect(screen.getByText('No matches')).toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — custom filter row', () => { + test('offers "Use filter" wording and dispatches the typed text live for number columns', () => { + const { store } = renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '10' }, { value: '20' }], + }) + openPopover('Value') + fireEvent.change(getInput('Value'), { + target: { value: '> 15' }, + }) + const row = screen.getByTestId('data-table-column-filter-custom-Value') + expect(row).toHaveTextContent('Use filter') + expect(row).toHaveTextContent('> 15') + + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'value', + filter: '> 15', + }) + }) + + test('strips letters typed into a numeric column, keeping the filter syntax characters', () => { + renderFilterInput({ + dataKey: 'value', + name: 'Value', + type: 'number', + options: [{ value: '10' }, { value: '20' }], + }) + openPopover('Value') + fireEvent.change(getInput('Value'), { + target: { value: 'abc> 1x5xyz' }, + }) + expect(getInput('Value')).toHaveValue('> 15') + }) + + test('offers "Contains" wording for string columns', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { + target: { value: 'medium' }, + }) + const row = screen.getByTestId('data-table-column-filter-custom-Legend') + expect(row).toHaveTextContent('Contains') + expect(row).toHaveTextContent('medium') + }) + + test('is hidden when the typed text exactly matches an existing option', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { + target: { value: 'High' }, + }) + expect( + screen.queryByTestId('data-table-column-filter-custom-Legend') + ).not.toBeInTheDocument() + }) + + test('clearing the typed text clears an already-applied custom filter live', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: 'medium' } + ) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) +}) + +describe('FilterInput searchable popover — keyboard behavior', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('Enter applies the highlighted checkbox and closes the popover', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + const input = getInput('Legend') + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['High'], + }) + expect(screen.queryByLabelText('High')).not.toBeInTheDocument() + }) + + test('Enter applies the custom filter directly with no prior arrow-navigation, and closes', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + const input = getInput('Legend') + fireEvent.change(input, { target: { value: 'medium' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: 'medium', + }) + expect( + screen.queryByTestId('data-table-column-filter-custom-Legend') + ).not.toBeInTheDocument() + }) + + test('Escape closes the popover without dispatching anything further', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + const input = getInput('Legend') + fireEvent.keyDown(input, { key: 'Escape' }) + + expect(store.getActions()).toEqual([]) + expect( + screen.queryByTestId('data-table-column-filter-custom-Legend') + ).not.toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — clear filter', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + + test('clearing an active array (checkbox) filter closes it out', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('clearing an active custom-string filter closes it out', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: 'medium' } + ) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('clearing empty text with no active filter dispatches nothing', () => { + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: '' } }) + expect(store.getActions()).toEqual([]) + }) + + test('checking the pinned "Any value" option collapses any existing selection into just SENTINEL_ANY_VALUE', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: [SENTINEL_ANY_VALUE], + }) + }) + + test('"Any value" is only checked when it is itself part of the selection - it does not track whether any filter is active', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + expect(screen.getByLabelText('Any value')).not.toBeChecked() + }) + + test('"Any value" stays checked when it is explicitly selected', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [SENTINEL_ANY_VALUE] } + ) + openPopover('Legend') + expect(screen.getByLabelText('Any value')).toBeChecked() + }) + + test('"Any value" stays visible while searching, unlike the narrowed checkbox list', () => { + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: 'hi' } }) + expect(screen.getByLabelText('Any value')).toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — reopening state', () => { + test('pre-fills the search box with an already-applied custom filter', () => { + const options = [{ value: '10' }, { value: '20' }] + renderFilterInput( + { dataKey: 'value', name: 'Value', type: 'number', options }, + { value: '> 5' } + ) + openPopover('Value') + expect(getInput('Value')).toHaveValue('> 5') + }) +}) + +describe('FilterInput searchable popover — dropdown placement', () => { + test('opens below the trigger by default', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + openPopover('Legend') + expect( + screen.getByLabelText('High').closest('.dropdownPopperAbove') + ).not.toBeInTheDocument() + }) + + test('flips above when the shared header row has no room to open below', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + renderFilterInput({ dataKey: 'legend', name: 'Legend', options }) + const trigger = getInput('Legend').closest('.filterTrigger') + jest.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + bottom: window.innerHeight - 50, + top: window.innerHeight - 78, + width: 100, + }) + openPopover('Legend') + expect( + screen.getByLabelText('High').closest('.dropdownPopperAbove') + ).toBeInTheDocument() + }) +}) + +describe('FilterInput searchable popover — reverse selection', () => { + const getReverseButton = (name) => + screen.getByTestId(`data-table-column-filter-reverse-${name}`) + + test('selects "Any value" when nothing is currently selected - every real value ends up ticked, which collapses to the wildcard', () => { + const options = [ + { value: 'High' }, + { value: 'Medium' }, + { value: 'Low' }, + ] + const { store } = renderFilterInput({ + dataKey: 'legend', + name: 'Legend', + options, + }) + openPopover('Legend') + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: [SENTINEL_ANY_VALUE], + }) + }) + + test("flips each value's checked state relative to the current selection", () => { + const options = [ + { value: 'High' }, + { value: 'Medium' }, + { value: 'Low' }, + ] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['Medium', 'Low'], + }) + }) + + test('includes "No value" in the values it flips', () => { + const options = [ + { value: '' }, + { value: 'Country' }, + { value: 'District' }, + ] + const { store } = renderFilterInput( + { dataKey: 'parentName', name: 'Parent', options }, + { parentName: ['Country'] } + ) + openPopover('Parent') + fireEvent.click(getReverseButton('Parent')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: ['', 'District'], + }) + }) + + test('collapses to "Any value" (plus "No value" if that was unset) when reversing ends up ticking every real value', () => { + const options = [{ value: '' }, { value: 'Country' }] + const { store } = renderFilterInput({ + dataKey: 'parentName', + name: 'Parent', + options, + }) + openPopover('Parent') + fireEvent.click(getReverseButton('Parent')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [SENTINEL_ANY_VALUE, ''], + }) + }) + + test('turns off "Any value" (and every real value with it) when it was active - there is no way to represent "all unticked" while it stays on', () => { + const options = [{ value: 'High' }, { value: 'Low' }] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High', SENTINEL_ANY_VALUE] } + ) + openPopover('Legend') + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('reversing while "Any value" is active flips "No value" independently, since it is unaffected by "Any value"', () => { + const options = [{ value: '' }, { value: 'Country' }] + const { store } = renderFilterInput( + { dataKey: 'parentName', name: 'Parent', options }, + { parentName: [SENTINEL_ANY_VALUE] } + ) + openPopover('Parent') + fireEvent.click(getReverseButton('Parent')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [''], + }) + }) + + test('ignores the current search text - inverts the full value list, not just what is visible', () => { + const options = [ + { value: 'High' }, + { value: 'Medium' }, + { value: 'Low' }, + ] + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High'] } + ) + openPopover('Legend') + fireEvent.change(getInput('Legend'), { target: { value: 'lo' } }) + fireEvent.click(getReverseButton('Legend')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['Medium', 'Low'], + }) + }) + + test('is disabled when the column has no known values to invert', () => { + renderFilterInput({ dataKey: 'name', name: 'Name' }) + openPopover('Name') + expect(getReverseButton('Name')).toBeDisabled() + }) +}) + +describe('FilterInput searchable popover — "Any value" / real value interaction', () => { + const options = [{ value: 'High' }, { value: 'Medium' }, { value: 'Low' }] + + test('every real value reads as checked while "Any value" is active', () => { + renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [SENTINEL_ANY_VALUE] } + ) + openPopover('Legend') + expect(screen.getByLabelText('High')).toBeChecked() + expect(screen.getByLabelText('Medium')).toBeChecked() + expect(screen.getByLabelText('Low')).toBeChecked() + }) + + test('"No value" does not read as checked just because "Any value" is active', () => { + renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }, + { parentName: [SENTINEL_ANY_VALUE] } + ) + openPopover('Parent') + expect(screen.getByLabelText('No value')).not.toBeChecked() + }) + + test('unticking one real value while "Any value" is active unticks "Any value" too, but keeps every other value ticked', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [SENTINEL_ANY_VALUE] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Medium')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: ['High', 'Low'], + }) + }) + + test('unticking a real value while "Any value" is active preserves "No value" if it was set', () => { + const { store } = renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'A' }, { value: 'B' }], + }, + { parentName: [SENTINEL_ANY_VALUE, ''] } + ) + openPopover('Parent') + fireEvent.click(screen.getByLabelText('A')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: ['B', ''], + }) + }) + + test('manually ticking every real value collapses the selection into "Any value"', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: ['High', 'Medium'] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Low')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'legend', + filter: [SENTINEL_ANY_VALUE], + }) + }) + + test('manually ticking every real value preserves "No value" while collapsing to "Any value"', () => { + const { store } = renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'A' }, { value: 'B' }], + }, + { parentName: ['A', ''] } + ) + openPopover('Parent') + fireEvent.click(screen.getByLabelText('B')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [SENTINEL_ANY_VALUE, ''], + }) + }) + + test('unchecking "Any value" unticks every real value too, not just the wildcard', () => { + const { store } = renderFilterInput( + { dataKey: 'legend', name: 'Legend', options }, + { legend: [SENTINEL_ANY_VALUE] } + ) + openPopover('Legend') + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_CLEAR, + layerId: 'layer1', + fieldId: 'legend', + }) + }) + + test('unchecking "Any value" preserves "No value" independently', () => { + const { store } = renderFilterInput( + { + dataKey: 'parentName', + name: 'Parent', + options: [{ value: '' }, { value: 'Country' }], + }, + { parentName: [SENTINEL_ANY_VALUE, ''] } + ) + openPopover('Parent') + fireEvent.click(screen.getByLabelText('Any value')) + expect(store.getActions()).toContainEqual({ + type: DATA_FILTER_SET, + layerId: 'layer1', + fieldId: 'parentName', + filter: [''], + }) + }) +}) diff --git a/src/components/datatable/__tests__/TableContextMenu.spec.jsx b/src/components/datatable/__tests__/TableContextMenu.spec.jsx new file mode 100644 index 000000000..71f81c655 --- /dev/null +++ b/src/components/datatable/__tests__/TableContextMenu.spec.jsx @@ -0,0 +1,69 @@ +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import { FEATURE_HIGHLIGHT } from '../../../constants/actionTypes.js' +import { FACILITY_LAYER } from '../../../constants/layers.js' +import TableContextMenu from '../TableContextMenu.jsx' + +jest.mock('../../cachedDataProvider/CachedDataProvider.jsx', () => ({ + useCachedData: () => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: ',' }, + }), +})) + +const mockStore = configureMockStore() + +const layer = { id: 'layer1', layer: FACILITY_LAYER, name: 'Test layer' } +const contextMenu = { x: 10, y: 10, featureProps: {} } + +const getZoomToFilteredLink = () => + screen + .getByTestId('data-table-context-menu-zoom-to-filtered') + .querySelector('a') + +const renderMenu = (props) => { + const store = mockStore({}) + const result = render( + + + + ) + return { ...result, store } +} + +describe('TableContextMenu — zoom to filtered features', () => { + test('is disabled when no filter is active (filteredIds is null)', () => { + renderMenu({ filteredIds: null }) + expect(getZoomToFilteredLink()).toHaveAttribute('aria-disabled', 'true') + }) + + test('is disabled when the filtered id list is empty', () => { + renderMenu({ filteredIds: [] }) + expect(getZoomToFilteredLink()).toHaveAttribute('aria-disabled', 'true') + }) + + test('dispatches highlightFeature with the filtered ids and closes the menu when clicked', () => { + const onClose = jest.fn() + const { store } = renderMenu({ + filteredIds: ['a', 'b', 'c'], + onClose, + }) + fireEvent.click(getZoomToFilteredLink()) + expect(store.getActions()).toContainEqual({ + type: FEATURE_HIGHLIGHT, + payload: { + ids: ['a', 'b', 'c'], + layerId: 'layer1', + origin: 'table', + zoom: true, + }, + }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index c883914a5..ae6f606a2 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -2,6 +2,10 @@ import { renderHook } from '@testing-library/react' import React from 'react' import { Provider } from 'react-redux' import configureMockStore from 'redux-mock-store' +import { + SENTINEL_SELECTED_ROW, + SENTINEL_NO_VALUE, +} from '../../../constants/dataTable.js' import { useTableData } from '../useTableData.js' jest.mock('../../map/MapApi.js', () => ({ @@ -44,17 +48,15 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(4) + expect(headers).toHaveLength(3) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(4) + expect(rows[0]).toHaveLength(3) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Facility 1', dataKey: 'name' }, { value: 'facility-1', dataKey: 'id' }, { value: 'Point', dataKey: 'type' }, @@ -96,9 +98,8 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(6) + expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Level', dataKey: 'level', type: 'number' }, @@ -106,9 +107,8 @@ describe('useTableData headers', () => { { name: 'Type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(6) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'OrgUnitName 1', dataKey: 'name' }, { value: 'orgunit-id-1', dataKey: 'id' }, { value: 3, dataKey: 'level' }, @@ -156,9 +156,8 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(10) + expect(headers).toHaveLength(9) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Value', dataKey: 'rawValue', type: 'number' }, @@ -175,9 +174,8 @@ describe('useTableData headers', () => { }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(10) + expect(rows[0]).toHaveLength(9) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, { value: 106.3, dataKey: 'rawValue' }, @@ -258,9 +256,8 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(8) + expect(headers).toHaveLength(7) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Org unit', dataKey: 'ouname', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { @@ -275,9 +272,8 @@ describe('useTableData headers', () => { { name: 'Type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(8) + expect(rows[0]).toHaveLength(7) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Lumley Hospital', dataKey: 'ouname' }, { value: 'a9712323629', dataKey: 'id' }, { value: '2023-05-15 00:00:00.0', dataKey: 'eventdate' }, @@ -438,9 +434,8 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(6) + expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, @@ -457,12 +452,11 @@ describe('useTableData headers', () => { type: 'number', }, ]) + expect(headers[3].roundFn).toBeInstanceOf(Function) expect(headers[4].roundFn).toBeInstanceOf(Function) - expect(headers[5].roundFn).toBeInstanceOf(Function) expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(6) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Bo', dataKey: 'name' }, { value: 'boOu', dataKey: 'id' }, { value: 'Polygon', dataKey: 'type' }, @@ -595,9 +589,8 @@ describe('useTableData headers', () => { ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(6) + expect(headers).toHaveLength(5) expect(headers).toMatchObject([ - { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, { name: 'Type', dataKey: 'type', type: 'string' }, @@ -614,12 +607,11 @@ describe('useTableData headers', () => { type: 'number', }, ]) + expect(headers[3].roundFn).toBeInstanceOf(Function) expect(headers[4].roundFn).toBeInstanceOf(Function) - expect(headers[5].roundFn).toBeInstanceOf(Function) expect(rows).toHaveLength(2) - expect(rows[0]).toHaveLength(6) + expect(rows[0]).toHaveLength(5) expect(rows[0]).toMatchObject([ - { value: 0, dataKey: 'index' }, { value: 'Badija', dataKey: 'name' }, { value: 'boOU', dataKey: 'id' }, { value: 'Polygon', dataKey: 'type' }, @@ -662,7 +654,7 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([5, 10, 15, null, null]) }) @@ -684,7 +676,7 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([15, 10, 5, null, null]) }) @@ -718,7 +710,7 @@ describe('useTableData sorting', () => { } ) - const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column + const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', undefined]) }) @@ -752,7 +744,7 @@ describe('useTableData sorting', () => { } ) - const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column + const nameColumn = result.current.rows.map((row) => row[0]?.value) // Name column expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', undefined]) }) @@ -792,7 +784,7 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([5, 10, null, null]) }) @@ -834,9 +826,91 @@ describe('useTableData sorting', () => { } ) - const valueColumn = result.current.rows.map((row) => row[3]?.value) // Value column + const valueColumn = result.current.rows.map((row) => row[2]?.value) // Value column expect(valueColumn).toEqual([null, null, null]) }) + + test('falls back to natural (index) order when sortField is null', () => { + const layerInInputOrder = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { properties: { id: '1', name: 'Item C', rawValue: 3 } }, + { properties: { id: '2', name: 'Item A', rawValue: 1 } }, + { properties: { id: '3', name: 'Item B', rawValue: 2 } }, + ], + } + const store = { aggregations: {} } + const { result } = renderHook( + () => + useTableData({ + layer: layerInInputOrder, + sortField: null, + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const names = result.current.rows.map( + (row) => row.find((c) => c.dataKey === 'name')?.value + ) + expect(names).toEqual(['Item C', 'Item A', 'Item B']) + }) + + describe('sorting by selection state', () => { + const layerWithIds = { + id: 'test-layer', + layer: 'thematic', + dataFilters: null, + data: [ + { properties: { id: '1', name: 'Item A' } }, + { properties: { id: '2', name: 'Item B' } }, + { properties: { id: '3', name: 'Item C' } }, + { properties: { id: '4', name: 'Item D' } }, + { properties: { id: '5', name: 'Item E' } }, + ], + } + const store = { aggregations: {} } + + const renderSorted = (sortDirection) => + renderHook( + () => + useTableData({ + layer: layerWithIds, + sortField: SENTINEL_SELECTED_ROW, + sortDirection, + selectedIdSet: new Set(['2', '4']), + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ).result + + test('ascending (the default on first click) puts selected rows first', () => { + const { current } = renderSorted('asc') + const ids = current.rows.map( + (row) => row.find((c) => c.dataKey === 'id')?.value + ) + expect(ids.slice(0, 2).sort()).toEqual(['2', '4']) + expect(ids.slice(2).sort()).toEqual(['1', '3', '5']) + }) + + test('descending puts selected rows last', () => { + const { current } = renderSorted('desc') + const ids = current.rows.map( + (row) => row.find((c) => c.dataKey === 'id')?.value + ) + expect(ids.slice(0, 3).sort()).toEqual(['1', '3', '5']) + expect(ids.slice(3).sort()).toEqual(['2', '4']) + }) + }) }) describe('useTableData showOnlyFeaturesInView', () => { @@ -920,7 +994,7 @@ describe('useTableData showOnlyFeaturesInView', () => { }) }) -describe('useTableData showOnlySelected', () => { +describe('useTableData selectionFilter', () => { const store = { aggregations: {} } const layer = { @@ -940,23 +1014,23 @@ describe('useTableData showOnlySelected', () => { ), }).result - test('includes all rows when the toggle is off', () => { + test('includes all rows when no filter is applied', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: false, + selectionFilter: [], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(2) }) - test('includes only selected rows when the toggle is on', () => { + test('includes only selected rows when filtered to "selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['selected'], selectedIdSet: new Set(['a']), }) expect(current.rows).toHaveLength(1) @@ -965,14 +1039,376 @@ describe('useTableData showOnlySelected', () => { ) }) - test('shows no rows when the toggle is on and nothing is selected', () => { + test('includes only non-selected rows when filtered to "not-selected"', () => { const { current } = renderTableData({ layer, sortField: 'name', sortDirection: 'asc', - showOnlySelected: true, + selectionFilter: ['not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item B' + ) + }) + + test('includes all rows when both options are checked', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected', 'not-selected'], + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('shows no rows when filtered to "selected" and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + selectionFilter: ['selected'], selectedIdSet: new Set(), }) expect(current.rows).toHaveLength(0) }) }) + +describe('useTableData columnOptions', () => { + const store = { aggregations: {} } + + const renderTableData = (layer) => + renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ).result + + test('gives options to every column type once distinct values are within the cap', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: [ + { + properties: { + id: 'ou1', + name: 'Org unit 1', + rawValue: 10, + legend: 'High', + range: '5 - 15', + level: 1, + parentName: 'Country', + type: 'Point', + color: '#ff0000', + }, + }, + { + properties: { + id: 'ou2', + name: 'Org unit 2', + rawValue: 20, + legend: 'Low', + range: '15 - 25', + level: 1, + parentName: 'Country', + type: 'Point', + color: '#00ff00', + }, + }, + ], + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.legend).toEqual([ + { value: 'High' }, + { value: 'Low' }, + ]) + expect(current.columnOptions.type).toEqual([{ value: 'Point' }]) + expect(current.columnOptions.name).toEqual([ + { value: 'Org unit 1' }, + { value: 'Org unit 2' }, + ]) + expect(current.columnOptions.id).toEqual([ + { value: 'ou1' }, + { value: 'ou2' }, + ]) + expect(current.columnOptions.parentName).toEqual([{ value: 'Country' }]) + expect(current.columnOptions.rawValue).toEqual([ + { value: '10' }, + { value: '20' }, + ]) + expect(current.columnOptions.level).toEqual([{ value: '1' }]) + }) + + test('gives options to a column even with many distinct values - no cap', () => { + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: Array.from({ length: 31 }, (_, i) => ({ + properties: { + id: `ou${i}`, + name: `Org unit ${i}`, + level: 1, + parentName: 'Country', + type: `Type${i}`, + }, + })), + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.type).toHaveLength(31) + }) + + test('sorts numeric column options numerically, not lexically', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: [10, 2, 33].map((rawValue, i) => ({ + properties: { + id: `ou${i}`, + name: `Org unit ${i}`, + rawValue, + legend: 'High', + range: '0 - 100', + level: 1, + parentName: 'Country', + type: 'Point', + color: '#ff0000', + }, + })), + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.rawValue).toEqual([ + { value: '2' }, + { value: '10' }, + { value: '33' }, + ]) + }) + + test('sorts range column options by their parsed bounds, not lexically', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: ['90 - 120', '9 - 12', '10 - 20'].map((range, i) => ({ + properties: { + id: `ou${i}`, + name: `Org unit ${i}`, + rawValue: 1, + legend: 'High', + range, + level: 1, + parentName: 'Country', + type: 'Point', + color: '#ff0000', + }, + })), + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.range).toEqual([ + { value: '9 - 12' }, + { value: '10 - 20' }, + { value: '90 - 120' }, + ]) + }) + + test('includes a SENTINEL_NO_VALUE option when some rows have a blank value', () => { + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { + properties: { + id: 'ou1', + name: 'Org unit 1', + level: 1, + parentName: 'Country', + type: 'Point', + }, + }, + { + properties: { + id: 'ou2', + name: 'Org unit 2', + level: 1, + parentName: '', + type: 'Point', + }, + }, + { + properties: { + id: 'ou3', + name: 'Org unit 3', + level: 1, + // parentName omitted entirely (undefined) + type: 'Point', + }, + }, + ], + } + + const { current } = renderTableData(layer) + + expect(current.columnOptions.parentName).toEqual([ + { value: SENTINEL_NO_VALUE }, + { value: 'Country' }, + ]) + }) + + test('exposes optionSet on event columns for later resolution by FilterInput', () => { + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Case classification', + valueType: 'TEXT', + optionSet: { id: 'xyz123' }, + }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 'CONFIRMED', + }, + }, + ], + } + + const { current } = renderTableData(layer) + + const header = current.headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + expect(header.optionSet).toEqual({ id: 'xyz123' }) + expect(current.columnOptions.AbCdEfGhIjK).toEqual([ + { value: 'CONFIRMED' }, + ]) + }) + + test('matches the currently sorted column direction, leaving other columns ascending', () => { + const layer = { + layer: 'thematic', + dataFilters: null, + data: [ + { + properties: { + id: 'ou1', + name: 'Org unit 1', + rawValue: 10, + legend: 'High', + level: 1, + parentName: 'Country', + type: 'Point', + }, + }, + { + properties: { + id: 'ou2', + name: 'Org unit 2', + rawValue: 20, + legend: 'Low', + level: 1, + parentName: 'Country', + type: 'Point', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'desc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + // Sorted column (name, desc) is reversed to match... + expect(result.current.columnOptions.name).toEqual([ + { value: 'Org unit 2' }, + { value: 'Org unit 1' }, + ]) + // ...while every other column stays in its default ascending order. + expect(result.current.columnOptions.rawValue).toEqual([ + { value: '10' }, + { value: '20' }, + ]) + expect(result.current.columnOptions.legend).toEqual([ + { value: 'High' }, + { value: 'Low' }, + ]) + }) +}) + +describe('useTableData globalSearch', () => { + const store = { aggregations: {} } + + const layer = { + layer: 'orgUnit', + dataFilters: null, + data: [ + { properties: { id: 'a', name: 'Kampala', parentName: 'Uganda' } }, + { properties: { id: 'b', name: 'Nairobi', parentName: 'Kenya' } }, + ], + } + + const renderTableData = (globalSearch) => + renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + globalSearch, + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ).result + + test('includes all rows when the search string is empty', () => { + const { current } = renderTableData('') + expect(current.rows).toHaveLength(2) + }) + + test('matches case-insensitively across any string column', () => { + const { current } = renderTableData('uganda') + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Kampala' + ) + }) + + test('shows no rows when nothing matches', () => { + const { current } = renderTableData('addis ababa') + expect(current.rows).toHaveLength(0) + }) +}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index fc84b8749..3ef784108 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -5,7 +5,7 @@ width: 100%; height: var(--data-table-height); z-index: 1040; - background: #fff; + background: var(--colors-white); display: flex; flex-direction: column; } @@ -47,6 +47,13 @@ flex-shrink: 0; } +.divider { + width: 1px; + height: 20px; + background-color: var(--colors-grey300); + flex-shrink: 0; +} + @keyframes tooltipExpandRight { from { clip-path: inset(0 100% 0 0); @@ -81,8 +88,8 @@ .clearBadge { position: absolute; - bottom: 0px; - right: 0px; + bottom: 0; + right: 0; width: 8px; height: 8px; background: var(--colors-grey100); @@ -128,6 +135,16 @@ padding: 0; } +.alignIcon1 { + display: flex; + margin-top: 1px; +} + +.alignIcon2 { + display: flex; + margin-top: 2px; +} + .clearFiltersButton:hover, .closeIcon:hover, .toggleButton:hover { @@ -135,6 +152,16 @@ background-color: var(--colors-grey300); } +.clearFiltersButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.clearFiltersButton:disabled:hover { + color: var(--colors-grey400); + background-color: transparent; +} + .toggleButton.active { color: var(--colors-blue700); background-color: var(--colors-blue100); @@ -144,6 +171,7 @@ background-color: var(--colors-blue200); } +/* !important beats @dhis2/ui's own ColorPicker field margin. */ .highlightColorPicker { margin-bottom: 0 !important; flex-shrink: 0; @@ -153,9 +181,24 @@ top: -1px; } +/* !important beats @dhis2/ui's own ColorPicker label size. */ .highlightColorPicker label { box-sizing: border-box; overflow: hidden; min-width: 18px !important; min-height: 18px !important; } + +.globalSearch { + flex: 0 1 160px; + min-width: 90px; +} + +.globalSearch > :global(div) { + width: 100%; +} + +.globalSearch :global(input.dense) { + padding: 4px 6px; + font-size: 11px; +} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 055c0ea10..068594371 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -1,5 +1,6 @@ .dataTable { height: 1px; + border: none !important; } .dataTable > :global(thead) { @@ -21,25 +22,65 @@ td.lightText { color: var(--colors-white); } +td.monoCell { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; +} + th.checkboxCell, td.checkboxCell { - width: 32px; - min-width: 32px; - max-width: 32px; + width: 76px; + min-width: 76px; + max-width: 76px; text-align: center; padding: 0; } +.checkboxHeaderContent { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; +} + +.selectionFilterButton { + width: 100%; + height: 24px; + font-size: 11px; + padding: 4px 6px; + border: 1px solid var(--colors-grey500); + border-radius: 3px; + box-shadow: inset 0 0 1px 0 rgba(48, 54, 60, 0.1); + background: var(--colors-white); + cursor: pointer; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selectionFilterPopover { + padding: var(--spacers-dp8); + min-width: 140px; + background-color: var(--colors-white); + border-radius: 4px; + box-shadow: var(--elevations-popover); +} + +/* !important beats @dhis2/ui's own Checkbox label font-size. */ +.selectionFilterPopover :global(label) { + font-size: 11px !important; +} + td.selected { background-color: var(--colors-blue050); } -/* Declared after .selected so a hovered and selected row still shows the hover color */ td.hovered { background-color: var(--colors-blue100); } -.columnHeader > :global(span.container) { +.columnHeader > :global(span.container), +.checkboxCell > :global(span.container) { justify-content: space-between; } @@ -50,6 +91,21 @@ td.hovered { gap: 2px; } +.reverseButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 26px; + height: 26px; + padding-top: 1px; + margin-right: -8px; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; +} + .sortButton { display: inline-flex; align-items: center; @@ -64,8 +120,8 @@ td.hovered { cursor: pointer; } -.sortButton:hover, -.sortButton:focus-visible { +.sortButton:hover:not(:disabled), +.sortButton:focus-visible:not(:disabled) { background: var(--colors-grey400); } @@ -73,20 +129,42 @@ td.hovered { outline: none; } +.sortButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.topTooltipContent { + z-index: 2000; + max-width: 300px; + padding: 4px 6px; + background-color: var(--colors-grey900); + border-radius: 3px; + color: var(--colors-white); + font-size: 13px; + line-height: 17px; + word-break: normal; + overflow-wrap: break-word; +} + .columnHeader :global(input.dense) { padding: 4px 6px; + font-size: 11px; } .columnHeader :global(input::placeholder) { color: var(--colors-grey400); } -/* Hide the filter icon */ .columnHeader + > :global(span.container) + > :global(span.top) + > button:last-of-type, +.checkboxCell > :global(span.container) > :global(span.top) > button:last-of-type { - visibility: hidden; + display: none; } .noResults { @@ -94,10 +172,39 @@ td.hovered { color: var(--colors-grey600); align-items: center; justify-content: center; + gap: var(--spacers-dp8); + font-size: 12px; font-style: italic; min-height: 40px; } +.clearFiltersLink { + font-size: 12px; + font-style: normal; + color: var(--colors-blue600); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + text-decoration: underline; +} + +.clearFiltersLink:hover { + color: var(--colors-blue700); +} + +.loadingContent { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacers-dp8); +} + +.loadingReason { + font-size: 12px; + color: var(--colors-grey700); +} + .noSupport { position: absolute; top: 50%; diff --git a/src/components/datatable/styles/FilterInput.module.css b/src/components/datatable/styles/FilterInput.module.css new file mode 100644 index 000000000..e3fec4932 --- /dev/null +++ b/src/components/datatable/styles/FilterInput.module.css @@ -0,0 +1,170 @@ +.filterTrigger { + position: relative; + display: flex; + align-items: center; + width: 100%; +} + +.filterTrigger :global(input.dense) { + padding: 4px 6px; + font-size: 11px; +} + +.filterTrigger :global(input::placeholder) { + color: var(--colors-grey400); +} + +.dropdownPopper { + background-color: var(--colors-white); + border-radius: 4px; + border-top-left-radius: 0; + border-top-right-radius: 0; + box-shadow: var(--elevations-popover); +} + +.dropdownPopperAbove { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.searchableFilterPopover { + display: flex; + flex-direction: column; + padding: var(--spacers-dp8); + min-width: 140px; + box-sizing: border-box; +} + +.reversedOrder .multiSelectPopover { + order: 0; +} + +.reversedOrder .pinnedOptions { + order: 1; + border-bottom: none; + border-top: 1px solid var(--colors-grey300); +} + +.reversedOrder .customFilterRow { + order: 2; +} + +.pinnedOptions { + position: relative; + width: 100%; + min-width: 0; + box-sizing: border-box; + border-bottom: 1px solid var(--colors-grey300); +} + +/* !important beats @dhis2/ui's own Checkbox label font-size. */ +.pinnedOptions :global(label) { + font-size: 11px !important; +} + +.reverseSelectionButton { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--colors-grey700); + cursor: pointer; +} + +.reverseSelectionButton:hover:not(:disabled) { + background: var(--colors-grey100); +} + +.reverseSelectionButton:disabled { + color: var(--colors-grey400); + cursor: not-allowed; +} + +.multiSelectPopover { + max-height: 260px; + overflow-y: auto; +} + +/* Same override as .pinnedOptions above. */ +.multiSelectPopover :global(label) { + font-size: 11px !important; +} + +.monoOption :global(label) { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; +} + +/* !important beats @dhis2/ui's own Checkbox label color. */ +.specialOption :global(label) { + color: var(--colors-grey700) !important; + font-style: italic; +} + +.noResults { + padding: 4px 2px; + font-size: 11px; + font-style: italic; + color: var(--colors-grey600); +} + +.customFilterRow { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + text-align: left; + font-size: 11px; + padding: 6px 8px; + border: none; + border-radius: 3px; + background: var(--colors-blue050); + color: var(--colors-blue700); + cursor: pointer; +} + +.customFilterRow svg { + flex-shrink: 0; +} + +.customFilterRow:hover, +.customFilterRow.highlighted { + background: var(--colors-blue100); +} + +.customFilterTag { + color: var(--colors-blue700); + white-space: nowrap; +} + +.customFilterExpr { + font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', 'Consolas', monospace; + font-weight: 600; + overflow-wrap: anywhere; +} + +.multiSelectPopover .highlighted { + background: var(--colors-grey100); +} + +.filterHelpTooltip { + z-index: 2000; + max-width: 300px; + padding: 4px 6px; + background-color: var(--colors-grey900); + border-radius: 3px; + color: var(--colors-white); + font-size: 11px; + line-height: 17px; + word-break: normal; + overflow-wrap: break-word; +} diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/styles/ResizeHandle.module.css index 2bec5229a..bec35a033 100644 --- a/src/components/datatable/styles/ResizeHandle.module.css +++ b/src/components/datatable/styles/ResizeHandle.module.css @@ -9,12 +9,18 @@ cursor: grab; } -.resizeHandle:hover, -.resizeHandle:active { - background-color: var(--colors-grey300); - color: var(--colors-grey900); +.gripBox { + width: 99%; + height: 24px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + color: var(--colors-grey600); } -.resizeHandle:active { - cursor: grabbing; +.resizeHandle:hover .gripBox, +.resizeHandle:active .gripBox { + background-color: var(--colors-grey200); + color: var(--colors-grey900); } diff --git a/src/components/datatable/useColumnWidths.js b/src/components/datatable/useColumnWidths.js new file mode 100644 index 000000000..2890420d8 --- /dev/null +++ b/src/components/datatable/useColumnWidths.js @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState } from 'react' + +export const useColumnWidths = ({ availableWidth, headers, error }) => { + const headerRowRef = useRef(null) + const minColumnWidthsRef = useRef([]) + const [columnWidths, setColumnWidths] = useState([]) + + useEffect(() => { + // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling + if (columnWidths.length === 0 && headerRowRef.current) { + const frameId = requestAnimationFrame(() => { + if (!headerRowRef.current) { + return + } + + const measuredColumnWidths = [] + + const dataCells = Array.from(headerRowRef.current.cells).slice( + 1 + ) + + for (const cell of dataCells) { + const rect = cell.getBoundingClientRect() + measuredColumnWidths.push(Math.floor(rect.width)) + } + + minColumnWidthsRef.current = measuredColumnWidths + setColumnWidths(measuredColumnWidths) + }) + + return () => cancelAnimationFrame(frameId) + } + }, [columnWidths]) + + useEffect(() => { + // Reset to auto layout for re-measurement when headers change + if (!error) { + minColumnWidthsRef.current = [] + setColumnWidths([]) + } + }, [headers, error]) + + useEffect(() => { + // Scale column widths proportionally on resize, clamped to initial measured widths + if (!error) { + setColumnWidths((prev) => { + if (prev.length === 0) { + return prev + } + const prevTotal = prev.reduce((sum, w) => sum + w, 0) + if (prevTotal === 0 || availableWidth === 0) { + return [] + } + const minWidths = minColumnWidthsRef.current + return prev.map((w, i) => + Math.max( + minWidths[i] ?? 0, + Math.round((w / prevTotal) * availableWidth) + ) + ) + }) + } + }, [availableWidth, error]) + + return { headerRowRef, columnWidths } +} diff --git a/src/components/datatable/useRowSelection.js b/src/components/datatable/useRowSelection.js new file mode 100644 index 000000000..870bda6bd --- /dev/null +++ b/src/components/datatable/useRowSelection.js @@ -0,0 +1,57 @@ +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) + const allRowIdSet = new Set(allRowIds) + const offViewSelected = selectedIds.filter((id) => !allRowIdSet.has(id)) + const invertedVisible = allRowIds.filter((id) => !selectedIdSet.has(id)) + return [...offViewSelected, ...invertedVisible] +} + +export const useRowSelection = ({ + selectedIds, + selectedIdSet, + allRowIds, + layerId, +}) => { + const dispatch = useDispatch() + + const allRowIdSet = useMemo(() => new Set(allRowIds), [allRowIds]) + + const isAllSelected = useMemo( + () => + allRowIds.length > 0 && + allRowIds.every((id) => selectedIdSet.has(id)), + [allRowIds, selectedIdSet] + ) + + const onToggleSelectAll = useCallback(() => { + const nextIds = isAllSelected + ? 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]) + + const onReverseSelection = useCallback(() => { + const nextIds = getReversedSelection(selectedIds, allRowIds) + + if (nextIds.length) { + dispatch(selectAllFeatures(nextIds, layerId)) + } else { + dispatch(clearSelection()) + } + }, [dispatch, selectedIds, allRowIds, layerId]) + + return { + isAllSelected, + onToggleSelectAll, + onReverseSelection, + } +} diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 55389a70a..21a20c080 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -1,6 +1,7 @@ import i18n from '@dhis2/d2-i18n' import { useMemo, useRef } from 'react' import { useSelector } from 'react-redux' +import { SENTINEL_NO_VALUE, SORT_ASCENDING } from '../../constants/dataTable.js' import { EVENT_LAYER, THEMATIC_LAYER, @@ -9,21 +10,22 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, } from '../../constants/layers.js' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../constants/selection.js' import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' -import { filterData } from '../../util/filter.js' +import { filterByGlobalSearch, filterData } from '../../util/filter.js' import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' -import { parseRange } from '../../util/legend.js' import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' +import { compareColumnOptionValues, compareRows } from '../../util/tableSort.js' import { isValidUid } from '../../util/uid.js' -const ASCENDING = 'asc' - const TYPE_NUMBER = 'number' const TYPE_STRING = 'string' const TYPE_DATE = 'date' -const INDEX = 'index' const NAME = 'name' const ID = 'id' const VALUE = 'rawValue' @@ -64,7 +66,6 @@ const getErrorCodeText = (code) => { } const defaultFieldsMap = () => ({ - [INDEX]: { name: i18n.t('Index'), dataKey: INDEX, type: TYPE_NUMBER }, [NAME]: { name: i18n.t('Name'), dataKey: NAME, type: TYPE_STRING }, [ID]: { name: i18n.t('Id'), dataKey: ID, type: TYPE_STRING }, [LEVEL]: { name: i18n.t('Level'), dataKey: LEVEL, type: TYPE_NUMBER }, @@ -98,25 +99,16 @@ const defaultFieldsMap = () => ({ }) const getThematicHeaders = () => - [ - INDEX, - NAME, - ID, - VALUE, - LEGEND, - RANGE, - LEVEL, - PARENT_NAME, - TYPE, - COLOR, - ].map((field) => defaultFieldsMap()[field]) + [NAME, ID, VALUE, LEGEND, RANGE, LEVEL, PARENT_NAME, TYPE, COLOR].map( + (field) => defaultFieldsMap()[field] + ) const getEventHeaders = ({ layerHeaders = [], styleDataItem, countEventsOutsideOrgUnits, }) => { - const fields = [INDEX, OUNAME, ID, EVENTDATE].map( + const fields = [OUNAME, ID, EVENTDATE].map( (field) => defaultFieldsMap()[field] ) @@ -133,6 +125,7 @@ const getEventHeaders = ({ !optionSet && numberValueTypes.includes(valueType) ? TYPE_NUMBER : TYPE_STRING, + optionSet: optionSet || null, })) customFields.push(defaultFieldsMap()[TYPE]) @@ -145,12 +138,12 @@ const getEventHeaders = ({ } const getOrgUnitHeaders = () => - [INDEX, NAME, ID, LEVEL, PARENT_NAME, TYPE].map( + [NAME, ID, LEVEL, PARENT_NAME, TYPE].map( (field) => defaultFieldsMap()[field] ) const getFacilityHeaders = () => - [INDEX, NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) + [NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) const toTitleCase = (str) => str.replace( @@ -186,7 +179,7 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { }) } - return [INDEX, NAME, ID, TYPE] + return [NAME, ID, TYPE] .map((field) => defaultFieldsMap()[field]) .concat(customFields) } @@ -196,6 +189,7 @@ const getGeoJsonUrlHeaders = (firstDataItem) => const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} +const EMPTY_COLUMN_OPTIONS = {} export const useTableData = ({ layer, @@ -203,8 +197,9 @@ export const useTableData = ({ sortDirection, showOnlyFeaturesInView, mapBounds, - showOnlySelected, + selectionFilter, selectedIdSet, + globalSearch, }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS @@ -257,6 +252,7 @@ export const useTableData = ({ .map((d, index) => ({ ...(d.properties || d), ...aggregations[d.id], + // Row-order tie-breaker for compareRows when no sortField is set index, })) // boundsDependency intentionally proxies mapBounds only while the toggle is on @@ -335,6 +331,41 @@ export const useTableData = ({ layerHeaders, ]) + const columnOptions = useMemo(() => { + if (!headers?.length || !dataWithAggregations?.length) { + return EMPTY_COLUMN_OPTIONS + } + + const result = {} + headers.forEach(({ dataKey, type }) => { + const seen = new Set() + for (const item of dataWithAggregations) { + const val = item[dataKey] + seen.add( + val === undefined || val === null || val === '' + ? SENTINEL_NO_VALUE + : String(val) + ) + } + + if (seen.size > 0) { + const direction = + dataKey === sortField ? sortDirection : SORT_ASCENDING + result[dataKey] = Array.from(seen) + .sort((a, b) => + compareColumnOptionValues(a, b, { + dataKey, + type, + direction, + }) + ) + .map((value) => ({ value })) + } + }) + + return Object.keys(result).length ? result : EMPTY_COLUMN_OPTIONS + }, [headers, dataWithAggregations, sortField, sortDirection]) + const rows = useMemo(() => { if (errorCode.current) { return null @@ -347,52 +378,36 @@ export const useTableData = ({ let filteredData = filterData(dataWithAggregations, dataFilters) - if (showOnlySelected) { - filteredData = filteredData.filter((item) => - selectedIdSet?.has(item.id) + if (globalSearch?.trim()) { + const stringDataKeys = headers + .filter((h) => h.type === TYPE_STRING) + .map((h) => h.dataKey) + filteredData = filterByGlobalSearch( + filteredData, + globalSearch, + stringDataKeys ) } - //sort - filteredData.sort((a, b) => { - const aVal = a[sortField] - const bVal = b[sortField] - - // All undefined values should be sorted to the end - if (aVal === undefined && bVal === undefined) { - return 0 - } - - if (aVal === undefined) { - return 1 // aVal goes to end - } - - if (bVal === undefined) { - return -1 // bVal goes to end - } - - if (typeof aVal === TYPE_NUMBER) { - return sortDirection === ASCENDING ? aVal - bVal : bVal - aVal - } - - if (sortField === RANGE) { - const [aStart, aEnd] = parseRange(aVal) - const [bStart, bEnd] = parseRange(bVal) - const startDiff = - sortDirection === ASCENDING - ? aStart - bStart - : bStart - aStart - if (startDiff !== 0) { - return startDiff - } - return sortDirection === ASCENDING ? aEnd - bEnd : bEnd - aEnd + if (selectionFilter?.length) { + const wantSelected = selectionFilter.includes( + SELECTION_FILTER_SELECTED + ) + const wantNotSelected = selectionFilter.includes( + SELECTION_FILTER_NOT_SELECTED + ) + // Both (or neither) checked means "show everything" + if (wantSelected !== wantNotSelected) { + filteredData = filteredData.filter( + (item) => !!selectedIdSet?.has(item.id) === wantSelected + ) } + } - // TODO: Make sure sorting works across different locales - return sortDirection === ASCENDING - ? aVal.localeCompare(bVal) - : bVal.localeCompare(aVal) - }) + //sort + filteredData.sort((a, b) => + compareRows(a, b, { sortField, sortDirection, selectedIdSet }) + ) return filteredData.map((item) => headers.map(({ dataKey, roundFn, type }) => { @@ -410,18 +425,27 @@ export const useTableData = ({ headers, dataWithAggregations, dataFilters, + globalSearch, sortField, sortDirection, - showOnlySelected, + selectionFilter, selectedIdSet, ]) // EE layers and event layers may be loading additional data - const isLoading = - (layerType === EARTH_ENGINE_LAYER && - aggregationType?.length && - (!aggregations || aggregations === EMPTY_AGGREGATIONS)) || - (layerType === EVENT_LAYER && !layer.isExtended && !serverCluster) + const isLoadingAggregations = + layerType === EARTH_ENGINE_LAYER && + aggregationType?.length && + (!aggregations || aggregations === EMPTY_AGGREGATIONS) + const isExtendingEvents = + layerType === EVENT_LAYER && !layer.isExtended && !serverCluster + const isLoading = isLoadingAggregations || isExtendingEvents + let loadingReason = null + if (isLoadingAggregations) { + loadingReason = i18n.t('Loading Earth Engine data…') + } else if (isExtendingEvents) { + loadingReason = i18n.t('Loading additional events…') + } const totalCount = dataWithAggregations?.length ?? 0 const filteredCount = rows?.length ?? 0 @@ -430,8 +454,10 @@ export const useTableData = ({ headers, rows, isLoading, + loadingReason, error: getErrorCodeText(errorCode.current), totalCount, filteredCount, + columnOptions, } } diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index 334080146..3358c2f28 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -57,10 +57,10 @@ class Map extends Component { nameProperty: PropTypes.string, resizeCount: PropTypes.number, selection: PropTypes.object, + selectionFilter: PropTypes.array, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, - showOnlySelected: PropTypes.bool, toggleFeatureSelection: PropTypes.func, zoom: PropTypes.number, } @@ -185,7 +185,7 @@ class Map extends Component { selection, highlightFeature, highlightColor, - showOnlySelected, + selectionFilter, clickFeature, toggleFeatureSelection, coordinatePopup: coordinates, @@ -237,7 +237,7 @@ class Map extends Component { selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={ toggleFeatureSelection diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 97a5acda7..6db6795fe 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -24,7 +24,7 @@ const MapContainer = ({ resizeCount, setMap }) => { ) const feature = useSelector((state) => state.feature) const selection = useSelector((state) => state.selection) - const { layersSorting, highlightColor, showOnlySelected } = useSelector( + const { layersSorting, highlightColor, selectionFilter } = useSelector( (state) => state.ui ) const basemapConfig = useBasemapConfig(basemap) @@ -52,7 +52,7 @@ const MapContainer = ({ resizeCount, setMap }) => { feature={feature} selection={selection} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} highlightFeature={debouncedHighlightFeature} clickFeature={(payload) => dispatch(clickFeature(payload))} toggleFeatureSelection={(id, layerId) => diff --git a/src/components/map/MapView.jsx b/src/components/map/MapView.jsx index 92a562417..53e354b3d 100644 --- a/src/components/map/MapView.jsx +++ b/src/components/map/MapView.jsx @@ -20,7 +20,7 @@ const MapView = (props) => { selection, highlightFeature, highlightColor, - showOnlySelected, + selectionFilter, clickFeature, toggleFeatureSelection, bounds, @@ -66,7 +66,7 @@ const MapView = (props) => { selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} interpretationModalOpen={interpretationModalOpen} @@ -87,7 +87,7 @@ const MapView = (props) => { selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} coordinatePopup={coordinatePopup} @@ -124,8 +124,8 @@ MapView.propTypes = { openContextMenu: PropTypes.func, resizeCount: PropTypes.number, selection: PropTypes.object, + selectionFilter: PropTypes.array, setMapObject: PropTypes.func, - showOnlySelected: PropTypes.bool, toggleFeatureSelection: PropTypes.func, } diff --git a/src/components/map/SplitView.jsx b/src/components/map/SplitView.jsx index c6cfab5e7..0a1f13bcd 100644 --- a/src/components/map/SplitView.jsx +++ b/src/components/map/SplitView.jsx @@ -16,7 +16,7 @@ const SplitView = ({ selection, highlightFeature, highlightColor, - showOnlySelected, + selectionFilter, clickFeature, toggleFeatureSelection, controls, @@ -101,7 +101,7 @@ const SplitView = ({ selection={selection} highlightFeature={highlightFeature} highlightColor={highlightColor} - showOnlySelected={showOnlySelected} + selectionFilter={selectionFilter} clickFeature={clickFeature} toggleFeatureSelection={toggleFeatureSelection} openContextMenu={openContextMenu} @@ -136,8 +136,8 @@ SplitView.propTypes = { layersSorting: PropTypes.bool, resizeCount: PropTypes.number, selection: PropTypes.object, + selectionFilter: PropTypes.array, setMapObject: PropTypes.func, - showOnlySelected: PropTypes.bool, toggleFeatureSelection: PropTypes.func, } diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index ab8c8a13f..a274a071b 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -7,6 +7,10 @@ import { PADDING_DEFAULT, DURATION_DEFAULT, } from '../../../constants/layers.js' +import { + SELECTION_FILTER_SELECTED, + SELECTION_FILTER_NOT_SELECTED, +} from '../../../constants/selection.js' export const idsEqual = (a, b) => a.length === b.length && a.every((id, i) => id === b[i]) @@ -33,7 +37,7 @@ class Layer extends PureComponent { opacity: PropTypes.number, openContextMenu: PropTypes.func, selection: PropTypes.object, - showOnlySelected: PropTypes.bool, + selectionFilter: PropTypes.array, toggleFeatureSelection: PropTypes.func, } @@ -145,14 +149,14 @@ class Layer extends PureComponent { } handleVisibleIdsChange(prevProps) { - const { selection, showOnlySelected } = this.props + const { selection, selectionFilter } = this.props if ( !idsEqual( this.getVisibleIds( prevProps.selection, - prevProps.showOnlySelected + prevProps.selectionFilter ) ?? [], - this.getVisibleIds(selection, showOnlySelected) ?? [] + this.getVisibleIds(selection, selectionFilter) ?? [] ) ) { this.updateVisibleIds() @@ -286,12 +290,33 @@ class Layer extends PureComponent { getVisibleIds( selection = this.props.selection, - showOnlySelected = this.props.showOnlySelected + selectionFilter = this.props.selectionFilter ) { - if (!showOnlySelected || selection?.layerId !== this.props.id) { + if (!selectionFilter?.length || selection?.layerId !== this.props.id) { + return null + } + + const wantSelected = selectionFilter.includes(SELECTION_FILTER_SELECTED) + const wantNotSelected = selectionFilter.includes( + SELECTION_FILTER_NOT_SELECTED + ) + + // Both (or neither, though that's already handled above) checked + // means "show everything" - same as no filter at all. + if (wantSelected === wantNotSelected) { return null } - return this.getSelectedIds(selection) + + const selectedIds = this.getSelectedIds(selection) + + if (wantSelected) { + return selectedIds + } + + const selectedIdSet = new Set(selectedIds) + return (this.props.data ?? []) + .map((feature) => feature.properties?.id ?? feature.id) + .filter((id) => id != null && !selectedIdSet.has(id)) } updateVisibleIds() { diff --git a/src/components/map/layers/__tests__/Layer.spec.js b/src/components/map/layers/__tests__/Layer.spec.js new file mode 100644 index 000000000..77d7660b8 --- /dev/null +++ b/src/components/map/layers/__tests__/Layer.spec.js @@ -0,0 +1,65 @@ +import Layer from '../Layer.js' + +const createLayer = (props) => { + const instance = Object.create(Layer.prototype) + instance.props = props + return instance +} + +describe('Layer#getVisibleIds', () => { + const data = [ + { properties: { id: 'a' } }, + { properties: { id: 'b' } }, + { properties: { id: 'c' } }, + ] + + test('returns null (show everything) when selectionFilter is empty', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a'] }, + selectionFilter: [], + }) + expect(layer.getVisibleIds()).toBe(null) + }) + + test('returns null when the selection belongs to a different layer', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'other-layer', ids: ['a'] }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toBe(null) + }) + + test('returns only the selected ids when filtered to "selected"', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a', 'c'] }, + selectionFilter: ['selected'], + }) + expect(layer.getVisibleIds()).toEqual(['a', 'c']) + }) + + test('returns only the non-selected ids when filtered to "not-selected"', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a'] }, + selectionFilter: ['not-selected'], + }) + expect(layer.getVisibleIds()).toEqual(['b', 'c']) + }) + + test('returns null (show everything) when both options are checked', () => { + const layer = createLayer({ + id: 'layer1', + data, + selection: { layerId: 'layer1', ids: ['a'] }, + selectionFilter: ['selected', 'not-selected'], + }) + expect(layer.getVisibleIds()).toBe(null) + }) +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 7b43834cd..0fbf8c5ce 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -42,8 +42,7 @@ export const DATA_TABLE_TOGGLE = 'DATA_TABLE_TOGGLE' export const DATA_TABLE_RESIZE = 'DATA_TABLE_RESIZE' export const MAP_BOUNDS_CHANGED = 'MAP_BOUNDS_CHANGED' export const TOGGLE_SHOW_ONLY_IN_VIEW = 'TOGGLE_SHOW_ONLY_IN_VIEW' -export const TOGGLE_SHOW_ONLY_SELECTED = 'TOGGLE_SHOW_ONLY_SELECTED' -export const SHOW_ONLY_SELECTED_SET = 'SHOW_ONLY_SELECTED_SET' +export const SELECTION_FILTER_SET = 'SELECTION_FILTER_SET' export const HIGHLIGHT_COLOR_SET = 'HIGHLIGHT_COLOR_SET' export const MAP_FEATURE_CLICKED = 'MAP_FEATURE_CLICKED' diff --git a/src/constants/dataTable.js b/src/constants/dataTable.js new file mode 100644 index 000000000..10d2e5967 --- /dev/null +++ b/src/constants/dataTable.js @@ -0,0 +1,6 @@ +export const SENTINEL_NO_VALUE = '' // Matches filterData's existing null/undefined -> empty-string coercion (src/util/filter.js) +export const SENTINEL_ANY_VALUE = '__any_value__' +export const SENTINEL_SELECTED_ROW = '__selected__' + +export const SORT_ASCENDING = 'asc' +export const SORT_DESCENDING = 'desc' diff --git a/src/constants/selection.js b/src/constants/selection.js new file mode 100644 index 000000000..68174c2b8 --- /dev/null +++ b/src/constants/selection.js @@ -0,0 +1,2 @@ +export const SELECTION_FILTER_SELECTED = 'selected' +export const SELECTION_FILTER_NOT_SELECTED = 'not-selected' diff --git a/src/reducers/__tests__/ui.spec.js b/src/reducers/__tests__/ui.spec.js index 70adf262a..e3e53fb76 100644 --- a/src/reducers/__tests__/ui.spec.js +++ b/src/reducers/__tests__/ui.spec.js @@ -26,29 +26,18 @@ describe('ui reducer — highlightColor', () => { }) }) -describe('ui reducer — showOnlySelected', () => { - it('defaults to false', () => { - expect(ui(undefined, {}).showOnlySelected).toBe(false) +describe('ui reducer — selectionFilter', () => { + it('defaults to an empty array', () => { + expect(ui(undefined, {}).selectionFilter).toEqual([]) }) - it('toggles on TOGGLE_SHOW_ONLY_SELECTED', () => { - const state = ui(undefined, { type: types.TOGGLE_SHOW_ONLY_SELECTED }) - expect(state.showOnlySelected).toBe(true) - - const toggledBack = ui(state, { - type: types.TOGGLE_SHOW_ONLY_SELECTED, - }) - expect(toggledBack.showOnlySelected).toBe(false) - }) - - it('sets an explicit value on SHOW_ONLY_SELECTED_SET', () => { - const prevState = { ...ui(undefined, {}), showOnlySelected: true } - const state = ui(prevState, { - type: types.SHOW_ONLY_SELECTED_SET, - value: false, + it('sets an explicit value on SELECTION_FILTER_SET', () => { + const state = ui(undefined, { + type: types.SELECTION_FILTER_SET, + value: ['selected'], }) - expect(state.showOnlySelected).toBe(false) + expect(state.selectionFilter).toEqual(['selected']) }) it.each([ @@ -56,11 +45,14 @@ describe('ui reducer — showOnlySelected', () => { types.MAP_SET, types.DATA_TABLE_CLOSE, types.DATA_TABLE_TOGGLE, - ])('resets to false on %s', (type) => { - const prevState = { ...ui(undefined, {}), showOnlySelected: true } + ])('resets to an empty array on %s', (type) => { + const prevState = { + ...ui(undefined, {}), + selectionFilter: ['selected'], + } const state = ui(prevState, { type }) - expect(state.showOnlySelected).toBe(false) + expect(state.selectionFilter).toEqual([]) }) }) diff --git a/src/reducers/ui.js b/src/reducers/ui.js index b5d4ca63d..45fb37be3 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -11,7 +11,7 @@ const defaultState = { layersSorting: false, mapBounds: null, showOnlyFeaturesInView: false, - showOnlySelected: false, + selectionFilter: [], highlightColor: null, lastClickedFeature: null, } @@ -51,7 +51,7 @@ const ui = (state = defaultState, action) => { return { ...state, rightPanelOpen: false, - showOnlySelected: false, + selectionFilter: [], lastClickedFeature: null, } @@ -59,7 +59,7 @@ const ui = (state = defaultState, action) => { case types.DATA_TABLE_TOGGLE: return { ...state, - showOnlySelected: false, + selectionFilter: [], } case types.DOWNLOAD_MODE_OPEN: @@ -103,16 +103,10 @@ const ui = (state = defaultState, action) => { showOnlyFeaturesInView: !state.showOnlyFeaturesInView, } - case types.TOGGLE_SHOW_ONLY_SELECTED: + case types.SELECTION_FILTER_SET: return { ...state, - showOnlySelected: !state.showOnlySelected, - } - - case types.SHOW_ONLY_SELECTED_SET: - return { - ...state, - showOnlySelected: action.value, + selectionFilter: action.value, } case types.HIGHLIGHT_COLOR_SET: diff --git a/src/util/__tests__/filter.spec.js b/src/util/__tests__/filter.spec.js index c23cda725..9f376df8e 100644 --- a/src/util/__tests__/filter.spec.js +++ b/src/util/__tests__/filter.spec.js @@ -1,4 +1,5 @@ -import { filterData } from '../filter.js' +import { SENTINEL_ANY_VALUE } from '../../constants/dataTable.js' +import { filterByGlobalSearch, filterData } from '../filter.js' describe('filterData', () => { it('should return the original data if no filters are provided', () => { @@ -68,4 +69,84 @@ describe('filterData', () => { const filters = { a: 'a', b: 'r' } expect(filterData(data, filters)).toEqual([{ a: 'banana', b: 'horse' }]) }) + + it('should OR-match an array filter against the raw stored value', () => { + const data = [{ a: 'High' }, { a: 'Medium' }, { a: 'Low' }] + const filters = { a: ['High', 'Low'] } + expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) + }) + + it('should not filter any rows when the array filter is empty', () => { + const data = [{ a: 'High' }, { a: 'Low' }] + const filters = { a: [] } + expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) + }) + + it('should match array filters against non-string values by exact string coercion', () => { + const data = [{ a: 1 }, { a: 2 }, { a: 3 }] + const filters = { a: ['1', '3'] } + expect(filterData(data, filters)).toEqual([{ a: 1 }, { a: 3 }]) + }) + + it('should combine an array filter on one field with a string filter on another', () => { + const data = [ + { a: 'High', b: 'cow' }, + { a: 'High', b: 'horse' }, + { a: 'Low', b: 'horse' }, + ] + const filters = { a: ['High'], b: 'horse' } + expect(filterData(data, filters)).toEqual([{ a: 'High', b: 'horse' }]) + }) + + it('SENTINEL_ANY_VALUE matches every row with a non-blank value, the opposite of the blank sentinel', () => { + const data = [{ a: 'High' }, { a: '' }, { a: null }, { a: 'Low' }] + const filters = { a: [SENTINEL_ANY_VALUE] } + expect(filterData(data, filters)).toEqual([{ a: 'High' }, { a: 'Low' }]) + }) + + it('combining SENTINEL_ANY_VALUE with the blank sentinel ("") matches every row', () => { + const data = [{ a: 'High' }, { a: '' }, { a: null }] + const filters = { a: [SENTINEL_ANY_VALUE, ''] } + expect(filterData(data, filters)).toEqual(data) + }) +}) + +describe('filterByGlobalSearch', () => { + const data = [ + { name: 'Kampala Hospital', type: 'Hospital' }, + { name: 'Entebbe Clinic', type: 'Clinic' }, + { name: 'Jinja Hospital', type: 'Hospital' }, + ] + + it('returns the original data when the search string is empty', () => { + expect(filterByGlobalSearch(data, '', ['name', 'type'])).toEqual(data) + expect(filterByGlobalSearch(data, ' ', ['name', 'type'])).toEqual( + data + ) + }) + + it('returns the original data when there are no string data keys', () => { + expect(filterByGlobalSearch(data, 'Kampala', [])).toEqual(data) + }) + + it('matches case-insensitively across any of the given fields', () => { + expect(filterByGlobalSearch(data, 'kampala', ['name', 'type'])).toEqual( + [{ name: 'Kampala Hospital', type: 'Hospital' }] + ) + }) + + it('matches rows where any field contains the search string', () => { + expect( + filterByGlobalSearch(data, 'hospital', ['name', 'type']) + ).toEqual([ + { name: 'Kampala Hospital', type: 'Hospital' }, + { name: 'Jinja Hospital', type: 'Hospital' }, + ]) + }) + + it('returns no rows when nothing matches', () => { + expect(filterByGlobalSearch(data, 'nairobi', ['name', 'type'])).toEqual( + [] + ) + }) }) diff --git a/src/util/__tests__/filterSelection.spec.js b/src/util/__tests__/filterSelection.spec.js new file mode 100644 index 000000000..7be52a843 --- /dev/null +++ b/src/util/__tests__/filterSelection.spec.js @@ -0,0 +1,145 @@ +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../../constants/dataTable.js' +import { + getInvertibleValues, + reverseSelection, + toggleAnyValue, + toggleRealValue, +} from '../filterSelection.js' + +describe('getInvertibleValues', () => { + it('includes the "No value" sentinel ahead of the real values when the column has blank cells', () => { + expect(getInvertibleValues(true, ['High', 'Low'])).toEqual([ + SENTINEL_NO_VALUE, + 'High', + 'Low', + ]) + }) + + it('omits the sentinel entirely when the column has no blank cells', () => { + expect(getInvertibleValues(false, ['High', 'Low'])).toEqual([ + 'High', + 'Low', + ]) + }) +}) + +describe('toggleAnyValue', () => { + it('activates "Any value" alone from an empty selection', () => { + expect(toggleAnyValue([])).toEqual([SENTINEL_ANY_VALUE]) + }) + + it('activates "Any value" while preserving an already-set "No value"', () => { + expect(toggleAnyValue([SENTINEL_NO_VALUE])).toEqual([ + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ]) + }) + + it('clears everything when deactivating "Any value" with "No value" unset', () => { + expect(toggleAnyValue([SENTINEL_ANY_VALUE])).toEqual([]) + }) + + it('deactivating "Any value" preserves "No value" independently', () => { + expect(toggleAnyValue([SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE])).toEqual( + [SENTINEL_NO_VALUE] + ) + }) +}) + +describe('toggleRealValue', () => { + const realValues = ['High', 'Medium', 'Low'] + + it('adds a value to the selection', () => { + expect(toggleRealValue(['High'], 'Low', realValues)).toEqual([ + 'High', + 'Low', + ]) + }) + + it('removes an already-selected value', () => { + expect(toggleRealValue(['High', 'Low'], 'Low', realValues)).toEqual([ + 'High', + ]) + }) + + it('collapses to "Any value" once every real value ends up checked', () => { + expect(toggleRealValue(['High', 'Medium'], 'Low', realValues)).toEqual([ + SENTINEL_ANY_VALUE, + ]) + }) + + it('collapsing to "Any value" preserves "No value" if it was set', () => { + expect( + toggleRealValue( + ['High', 'Medium', SENTINEL_NO_VALUE], + 'Low', + realValues + ) + ).toEqual([SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE]) + }) + + it('unticking one real value while "Any value" is active splits it back into an explicit list', () => { + expect( + toggleRealValue([SENTINEL_ANY_VALUE], 'Medium', realValues) + ).toEqual(['High', 'Low']) + }) + + it('unticking a real value while "Any value" is active preserves "No value" if it was set', () => { + expect( + toggleRealValue( + [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE], + 'Medium', + realValues + ) + ).toEqual(['High', 'Low', SENTINEL_NO_VALUE]) + }) +}) + +describe('reverseSelection', () => { + const realValues = ['High', 'Medium', 'Low'] + + it('selects "Any value" when nothing is currently selected', () => { + expect(reverseSelection([], realValues, false)).toEqual([ + SENTINEL_ANY_VALUE, + ]) + }) + + it("flips each value's checked state relative to the current selection", () => { + expect(reverseSelection(['High'], realValues, false)).toEqual([ + 'Medium', + 'Low', + ]) + }) + + it('includes "No value" in the values it flips', () => { + expect( + reverseSelection(['Country'], ['Country', 'District'], true) + ).toEqual([SENTINEL_NO_VALUE, 'District']) + }) + + it('collapses to "Any value" (plus "No value" if that was unset) when reversing ends up ticking every real value', () => { + expect(reverseSelection([], ['Country'], true)).toEqual([ + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, + ]) + }) + + it('clears everything when reversing turns off an active "Any value" (there is no way to represent "all unticked" while it stays on)', () => { + expect( + reverseSelection(['High', SENTINEL_ANY_VALUE], realValues, false) + ).toEqual([]) + }) + + it('reversing while "Any value" is active flips "No value" independently, since it is unaffected by "Any value"', () => { + expect( + reverseSelection([SENTINEL_ANY_VALUE], ['Country'], true) + ).toEqual([SENTINEL_NO_VALUE]) + }) + + it('is a no-op array when there are no invertible values', () => { + expect(reverseSelection([], [], false)).toEqual([]) + }) +}) diff --git a/src/util/__tests__/tableSort.spec.js b/src/util/__tests__/tableSort.spec.js new file mode 100644 index 000000000..b34c46350 --- /dev/null +++ b/src/util/__tests__/tableSort.spec.js @@ -0,0 +1,190 @@ +import { + SENTINEL_NO_VALUE, + SENTINEL_SELECTED_ROW, +} from '../../constants/dataTable.js' +import { + compareBySelected, + compareColumnOptionValues, + compareFieldValues, + compareRangeValues, + compareRows, +} from '../tableSort.js' + +describe('compareFieldValues', () => { + it('sorts numbers ascending', () => { + expect( + compareFieldValues(5, 10, { sortDirection: 'asc' }) + ).toBeLessThan(0) + }) + + it('sorts numbers descending', () => { + expect( + compareFieldValues(5, 10, { sortDirection: 'desc' }) + ).toBeGreaterThan(0) + }) + + it('sorts strings ascending, locale-aware', () => { + expect( + compareFieldValues('Apple', 'Banana', { sortDirection: 'asc' }) + ).toBeLessThan(0) + }) + + it('sorts strings descending', () => { + expect( + compareFieldValues('Apple', 'Banana', { sortDirection: 'desc' }) + ).toBeGreaterThan(0) + }) + + it('sorts undefined values to the end regardless of direction', () => { + expect( + compareFieldValues(undefined, 5, { sortDirection: 'asc' }) + ).toBeGreaterThan(0) + expect( + compareFieldValues(5, undefined, { sortDirection: 'desc' }) + ).toBeLessThan(0) + }) + + it('treats two undefined values as equal', () => { + expect( + compareFieldValues(undefined, undefined, { sortDirection: 'asc' }) + ).toBe(0) + }) + + it('delegates to compareRangeValues for the Range column', () => { + expect( + compareFieldValues('5-10', '1-3', { + sortField: 'range', + sortDirection: 'asc', + }) + ).toBeGreaterThan(0) + }) +}) + +describe('compareRangeValues', () => { + it('compares by range start first', () => { + expect(compareRangeValues('10-20', '1-5', 'asc')).toBeGreaterThan(0) + }) + + it('falls back to range end when starts are equal', () => { + expect(compareRangeValues('1-20', '1-5', 'asc')).toBeGreaterThan(0) + }) + + it('reverses order when descending', () => { + expect(compareRangeValues('10-20', '1-5', 'desc')).toBeLessThan(0) + }) +}) + +describe('compareBySelected', () => { + const selectedIdSet = new Set(['1']) + + it('puts selected rows first when ascending', () => { + expect( + compareBySelected( + { id: '1' }, + { id: '2' }, + { selectedIdSet, sortDirection: 'asc' } + ) + ).toBeLessThan(0) + }) + + it('puts selected rows last when descending', () => { + expect( + compareBySelected( + { id: '1' }, + { id: '2' }, + { selectedIdSet, sortDirection: 'desc' } + ) + ).toBeGreaterThan(0) + }) +}) + +describe('compareRows', () => { + it('falls back to natural (index) order when there is no sortField', () => { + expect( + compareRows({ index: 2 }, { index: 0 }, { sortField: null }) + ).toBeGreaterThan(0) + }) + + it('sorts by the selected-row sentinel field via compareBySelected', () => { + const selectedIdSet = new Set(['1']) + expect( + compareRows( + { id: '1' }, + { id: '2' }, + { + sortField: SENTINEL_SELECTED_ROW, + sortDirection: 'asc', + selectedIdSet, + } + ) + ).toBeLessThan(0) + }) + + it('otherwise sorts by the named field via compareFieldValues', () => { + expect( + compareRows( + { rawValue: 5 }, + { rawValue: 10 }, + { sortField: 'rawValue', sortDirection: 'asc' } + ) + ).toBeLessThan(0) + }) +}) + +describe('compareColumnOptionValues', () => { + it('sorts the blank-cell sentinel first, regardless of direction', () => { + expect( + compareColumnOptionValues(SENTINEL_NO_VALUE, 'High', { + dataKey: 'legend', + type: 'string', + direction: 'desc', + }) + ).toBeLessThan(0) + expect( + compareColumnOptionValues('High', SENTINEL_NO_VALUE, { + dataKey: 'legend', + type: 'string', + direction: 'asc', + }) + ).toBeGreaterThan(0) + }) + + it('compares numeric-typed columns numerically', () => { + expect( + compareColumnOptionValues('10', '2', { + dataKey: 'value', + type: 'number', + direction: 'asc', + }) + ).toBeGreaterThan(0) + }) + + it('compares string-typed columns lexically', () => { + expect( + compareColumnOptionValues('Apple', 'Banana', { + dataKey: 'legend', + type: 'string', + direction: 'asc', + }) + ).toBeLessThan(0) + }) + + it('delegates Range columns to compareRangeValues', () => { + expect( + compareColumnOptionValues('10-20', '1-5', { + dataKey: 'range', + type: 'string', + direction: 'asc', + }) + ).toBeGreaterThan(0) + }) + + it('defaults to ascending when no direction is given', () => { + expect( + compareColumnOptionValues('Apple', 'Banana', { + dataKey: 'legend', + type: 'string', + }) + ).toBeLessThan(0) + }) +}) diff --git a/src/util/filter.js b/src/util/filter.js index ae5d72296..f4793a296 100644 --- a/src/util/filter.js +++ b/src/util/filter.js @@ -1,3 +1,5 @@ +import { SENTINEL_ANY_VALUE } from '../constants/dataTable.js' + // Filters an array of object with a set of filters export const filterData = (data, filters) => { if (!filters) { @@ -15,6 +17,16 @@ export const filterData = (data, filters) => { const props = d.properties || d // GeoJSON or plain object const value = props[field] + if (Array.isArray(filter)) { + // Multi-select: OR match against the raw stored value + const stringValue = value == null ? '' : String(value) + return ( + filter.length === 0 || + filter.includes(stringValue) || + (stringValue !== '' && filter.includes(SENTINEL_ANY_VALUE)) + ) + } + return typeof value === 'number' ? numericFilter(value, filter) : stringFilter(value, filter) @@ -40,6 +52,21 @@ export const numericFilter = (value, filter) => { }) } +// Case-insensitive match against any of the given string fields +export const filterByGlobalSearch = (data, searchString, stringDataKeys) => { + if (!searchString?.trim() || !stringDataKeys?.length) { + return data + } + const lower = searchString.toLowerCase() + return data.filter((item) => { + const props = item.properties || item + return stringDataKeys.some((key) => { + const val = props[key] + return val != null && String(val).toLowerCase().includes(lower) + }) + }) +} + // Returns true if the filter is true const isTrueFilter = (value, filter) => { if (filter.includes('>=')) { diff --git a/src/util/filterSelection.js b/src/util/filterSelection.js new file mode 100644 index 000000000..cd2aa564b --- /dev/null +++ b/src/util/filterSelection.js @@ -0,0 +1,66 @@ +import { + SENTINEL_ANY_VALUE, + SENTINEL_NO_VALUE, +} from '../constants/dataTable.js' + +export const getInvertibleValues = (hasNotSetOption, realValues) => + hasNotSetOption ? [SENTINEL_NO_VALUE, ...realValues] : realValues + +export const toggleAnyValue = (selected) => { + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + const keepNotSet = selected.includes(SENTINEL_NO_VALUE) + if (anyValueActive) { + return keepNotSet ? [SENTINEL_NO_VALUE] : [] + } + return keepNotSet + ? [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE] + : [SENTINEL_ANY_VALUE] +} + +export const toggleRealValue = (selected, value, realValues) => { + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + const keepNotSet = selected.includes(SENTINEL_NO_VALUE) + + if (anyValueActive) { + const next = realValues.filter((v) => v !== value) + return keepNotSet ? [...next, SENTINEL_NO_VALUE] : next + } + + const next = selected.includes(value) + ? selected.filter((v) => v !== value) + : [...selected, value] + + const allRealValuesChecked = + realValues.length > 0 && realValues.every((v) => next.includes(v)) + if (!allRealValuesChecked) { + return next + } + return next.includes(SENTINEL_NO_VALUE) + ? [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE] + : [SENTINEL_ANY_VALUE] +} + +export const reverseSelection = (selected, realValues, hasNotSetOption) => { + const anyValueActive = selected.includes(SENTINEL_ANY_VALUE) + + const invertedRealValues = anyValueActive + ? [] + : realValues.filter((v) => !selected.includes(v)) + const invertedNotSet = + hasNotSetOption && !selected.includes(SENTINEL_NO_VALUE) + + const allRealValuesInverted = + !anyValueActive && + realValues.length > 0 && + invertedRealValues.length === realValues.length + + if (allRealValuesInverted) { + return invertedNotSet + ? [SENTINEL_ANY_VALUE, SENTINEL_NO_VALUE] + : [SENTINEL_ANY_VALUE] + } + + return invertedNotSet + ? [SENTINEL_NO_VALUE, ...invertedRealValues] + : invertedRealValues +} diff --git a/src/util/tableSort.js b/src/util/tableSort.js new file mode 100644 index 000000000..6af6052b8 --- /dev/null +++ b/src/util/tableSort.js @@ -0,0 +1,96 @@ +import { + SENTINEL_NO_VALUE, + SENTINEL_SELECTED_ROW, + SORT_ASCENDING, +} from '../constants/dataTable.js' +import { parseRange } from './legend.js' + +const RANGE = 'range' + +const compareStrings = (a, b) => { + if (a < b) { + return -1 + } + if (a > b) { + return 1 + } + return 0 +} + +export const compareColumnOptionValues = ( + a, + b, + { dataKey, type, direction = SORT_ASCENDING } +) => { + if (a === SENTINEL_NO_VALUE) { + return -1 + } + if (b === SENTINEL_NO_VALUE) { + return 1 + } + if (dataKey === RANGE) { + return compareRangeValues(a, b, direction) + } + const comparison = + type === 'number' ? Number(a) - Number(b) : compareStrings(a, b) + return direction === SORT_ASCENDING ? comparison : -comparison +} + +// Ascending (the default on first click) puts selected rows first +export const compareBySelected = (a, b, { selectedIdSet, sortDirection }) => { + const aSelected = selectedIdSet?.has(a.id) ? 1 : 0 + const bSelected = selectedIdSet?.has(b.id) ? 1 : 0 + return sortDirection === SORT_ASCENDING + ? bSelected - aSelected + : aSelected - bSelected +} + +export const compareRangeValues = (aVal, bVal, sortDirection) => { + const [aStart, aEnd] = parseRange(aVal) + const [bStart, bEnd] = parseRange(bVal) + const startDiff = + sortDirection === SORT_ASCENDING ? aStart - bStart : bStart - aStart + if (startDiff !== 0) { + return startDiff + } + return sortDirection === SORT_ASCENDING ? aEnd - bEnd : bEnd - aEnd +} + +export const compareFieldValues = ( + aVal, + bVal, + { sortField, sortDirection } +) => { + // All undefined values should be sorted to the end + if (aVal === undefined && bVal === undefined) { + return 0 + } + if (aVal === undefined) { + return 1 + } + if (bVal === undefined) { + return -1 + } + if (typeof aVal === 'number') { + return sortDirection === SORT_ASCENDING ? aVal - bVal : bVal - aVal + } + if (sortField === RANGE) { + return compareRangeValues(aVal, bVal, sortDirection) + } + // TODO: Make sure sorting works across different locales + return sortDirection === SORT_ASCENDING + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal) +} + +export const compareRows = (a, b, options) => { + const { sortField } = options + // "None" (third click of the cycle) - fall back to natural order + if (!sortField) { + return a.index - b.index + } + if (sortField === SENTINEL_SELECTED_ROW) { + return compareBySelected(a, b, options) + } + return compareFieldValues(a[sortField], b[sortField], options) +}