From e0c2805ce706c1cb4906a558980d5ae98a03c9db Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 17:36:21 +0200 Subject: [PATCH 1/8] feat: add bidirectional map/table selection sync and collapsible data table --- i18n/en.pot | 27 +- src/actions/dataTable.js | 23 ++ src/actions/feature.js | 5 + src/actions/selection.js | 23 ++ src/components/core/ColorPicker.jsx | 83 +++-- .../core/__tests__/ColorPicker.spec.jsx | 68 ++++ src/components/core/icons.jsx | 53 ++++ .../core/styles/ColorPicker.module.css | 13 + src/components/datatable/BottomPanel.jsx | 162 ++++++++-- src/components/datatable/DataTable.jsx | 291 +++++++++++++++--- src/components/datatable/ResizeHandle.jsx | 4 + src/components/datatable/TableContextMenu.jsx | 35 ++- .../datatable/__tests__/DataTable.spec.jsx | 59 +++- .../datatable/__tests__/useTableData.spec.jsx | 138 +++++++++ .../datatable/styles/BottomPanel.module.css | 34 +- .../datatable/styles/DataTable.module.css | 19 ++ .../datatable/styles/ResizeHandle.module.css | 3 +- src/components/datatable/useTableData.js | 52 +++- src/components/map/ContextMenu.jsx | 53 +++- src/components/map/Map.jsx | 20 ++ src/components/map/MapContainer.jsx | 31 +- src/components/map/MapPosition.jsx | 34 +- src/components/map/MapView.jsx | 24 ++ src/components/map/SplitView.jsx | 18 ++ src/components/map/layers/EventLayer.jsx | 13 +- src/components/map/layers/FacilityLayer.jsx | 10 +- src/components/map/layers/GeoJsonLayer.js | 12 + src/components/map/layers/Layer.js | 142 ++++++++- src/components/map/layers/OrgUnitLayer.jsx | 8 +- src/components/map/layers/ThematicLayer.jsx | 66 ++-- .../map/layers/TrackedEntityLayer.jsx | 20 +- .../layers/earthEngine/EarthEngineLayer.jsx | 14 +- src/components/plugin/Map.jsx | 5 + src/constants/actionTypes.js | 12 + .../useDebouncedHighlightFeature.spec.js | 86 ++++++ src/hooks/useDebouncedHighlightFeature.js | 45 +++ src/reducers/__tests__/selection.spec.js | 149 +++++++++ src/reducers/__tests__/ui.spec.js | 91 ++++++ src/reducers/index.js | 2 + src/reducers/selection.js | 49 +++ src/reducers/ui.js | 55 ++++ src/util/__tests__/geojson.spec.js | 138 +++++++++ src/util/geojson.js | 21 ++ 43 files changed, 2058 insertions(+), 152 deletions(-) create mode 100644 src/actions/selection.js create mode 100644 src/components/core/__tests__/ColorPicker.spec.jsx create mode 100644 src/hooks/__tests__/useDebouncedHighlightFeature.spec.js create mode 100644 src/hooks/useDebouncedHighlightFeature.js create mode 100644 src/reducers/__tests__/selection.spec.js create mode 100644 src/reducers/__tests__/ui.spec.js create mode 100644 src/reducers/selection.js diff --git a/i18n/en.pot b/i18n/en.pot index 3f8dd50421..39bac3b449 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -161,15 +161,33 @@ msgstr "{{filtered}} of {{total}} rows" msgid "{{total}} rows" msgstr "{{total}} rows" +msgid "Restore" +msgstr "Restore" + +msgid "Collapse" +msgstr "Collapse" + msgid "Clear filters" msgstr "Clear filters" +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 "No results found" msgstr "No results found" +msgid "Select all" +msgstr "Select all" + msgid "Sort by {{column}}" msgstr "Sort by {{column}}" @@ -191,6 +209,12 @@ msgstr "View profile" msgid "Zoom to feature" msgstr "Zoom to feature" +msgid "Zoom to layer" +msgstr "Zoom to layer" + +msgid "Zoom to selected features" +msgstr "Zoom to selected 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." @@ -649,9 +673,6 @@ msgstr "" "Choose which layer sources are available to add to maps. This selection " "applies to all users." -msgid "Collapse" -msgstr "Collapse" - msgid "Expand" msgstr "Expand" diff --git a/src/actions/dataTable.js b/src/actions/dataTable.js index 392ebadf48..133e680b73 100644 --- a/src/actions/dataTable.js +++ b/src/actions/dataTable.js @@ -13,3 +13,26 @@ export const resizeDataTable = (height) => ({ type: types.DATA_TABLE_RESIZE, height, }) + +export const setMapBounds = (bounds) => ({ + type: types.MAP_BOUNDS_CHANGED, + bounds, +}) + +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, + value, +}) + +export const setHighlightColor = (color) => ({ + type: types.HIGHLIGHT_COLOR_SET, + color, +}) diff --git a/src/actions/feature.js b/src/actions/feature.js index 4c54bdc3ca..bd8dadefa8 100644 --- a/src/actions/feature.js +++ b/src/actions/feature.js @@ -13,3 +13,8 @@ export const setFeatureProfile = (payload) => ({ export const closeFeatureProfile = () => ({ type: types.FEATURE_PROFILE_CLOSE, }) + +export const clickFeature = (payload) => ({ + type: types.MAP_FEATURE_CLICKED, + payload, +}) diff --git a/src/actions/selection.js b/src/actions/selection.js new file mode 100644 index 0000000000..6b5795c46e --- /dev/null +++ b/src/actions/selection.js @@ -0,0 +1,23 @@ +import * as types from '../constants/actionTypes.js' + +export const toggleFeatureSelection = (id, layerId) => ({ + type: types.FEATURE_TOGGLE_SELECTION, + id, + layerId, +}) + +export const selectAllFeatures = (ids, layerId) => ({ + type: types.SELECTION_SET_ALL, + ids, + layerId, +}) + +export const selectFeatureRange = (ids, layerId) => ({ + type: types.SELECTION_ADD_RANGE, + ids, + layerId, +}) + +export const clearSelection = () => ({ + type: types.SELECTION_CLEAR, +}) diff --git a/src/components/core/ColorPicker.jsx b/src/components/core/ColorPicker.jsx index da139aebb1..cc93a59397 100644 --- a/src/components/core/ColorPicker.jsx +++ b/src/components/core/ColorPicker.jsx @@ -1,42 +1,69 @@ -import { IconChevronDown24 } from '@dhis2/ui' +import { IconChevronDown16, IconChevronDown24 } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' import React, { Fragment } from 'react' import { isDarkColor } from '../../util/colors.js' import styles from './styles/ColorPicker.module.css' -const ColorPicker = ({ color, label, width, height, onChange, className }) => ( - -
- {label &&
{label}
} - +
+
+ ) +} ColorPicker.propTypes = { - color: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, + centerIcon: PropTypes.bool, className: PropTypes.string, + color: PropTypes.string, height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), label: PropTypes.string, width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), diff --git a/src/components/core/__tests__/ColorPicker.spec.jsx b/src/components/core/__tests__/ColorPicker.spec.jsx new file mode 100644 index 0000000000..1ba966a74b --- /dev/null +++ b/src/components/core/__tests__/ColorPicker.spec.jsx @@ -0,0 +1,68 @@ +import { render } from '@testing-library/react' +import React from 'react' +import ColorPicker from '../ColorPicker.jsx' + +describe('ColorPicker', () => { + it('uses the default 24px chevron at the default size', () => { + const { container } = render( + + ) + expect(container.querySelector('svg').getAttribute('width')).toBe('24') + }) + + it('uses a compact 16px chevron when height is small', () => { + const { container } = render( + + ) + expect(container.querySelector('svg').getAttribute('width')).toBe('16') + }) + + it('right-aligns the chevron by default (unchanged look for existing pickers)', () => { + const { container } = render( + + ) + expect(container.querySelector('span').className).toContain('icon') + expect(container.querySelector('span').className).not.toContain( + 'iconCentered' + ) + }) + + it('centers the chevron only when centerIcon is set (data-table swatch)', () => { + const { container } = render( + + ) + expect(container.querySelector('span').className).toContain( + 'iconCentered' + ) + }) + + it('renders an empty/dashed swatch instead of a solid fill when no color is set', () => { + const { container } = render( + + ) + const label = container.querySelector('label') + expect(label.className).toContain('unset') + expect(label.style.backgroundColor).toBe('') + }) + + it('still gives the native color input a real value when unset', () => { + const { container } = render( + + ) + expect(container.querySelector('input').value).not.toBe('') + }) + + it('renders a solid fill (not the unset style) once a color is set', () => { + const { container } = render( + + ) + const label = container.querySelector('label') + expect(label.className).not.toContain('unset') + expect(label.style.backgroundColor).toBe('rgb(255, 0, 0)') + }) +}) diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index b46a6354f5..78de9d4a22 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -49,6 +49,59 @@ 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) const activeLayerId = useSelector((state) => state.dataTable) @@ -28,28 +51,62 @@ const BottomPanel = () => { ) const dataFilters = activeLayer?.dataFilters ?? {} const hasActiveFilters = Object.keys(dataFilters).length > 0 + 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 highlightColor = useSelector((state) => state.ui.highlightColor) const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) const nameRef = useRef(null) + const isDraggingRef = useRef(false) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) const [nameTooltipPos, setNameTooltipPos] = useState(null) + const [isCollapsed, setIsCollapsed] = useState(false) const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight + const displayHeight = isCollapsed ? COLLAPSED_HEIGHT : tableHeight + + const toggleCollapsed = useCallback( + () => setIsCollapsed((collapsed) => !collapsed), + [] + ) + + const onResizeStart = useCallback(() => { + isDraggingRef.current = true + }, []) const onResize = useCallback((h) => { + setIsCollapsed(h <= MIN_HEIGHT) document.documentElement.style.setProperty( '--data-table-height', - `${h}px` + `${h <= MIN_HEIGHT ? COLLAPSED_HEIGHT : h}px` ) }, []) + const onResizeEnd = useCallback( + (h) => { + isDraggingRef.current = false + if (h <= MIN_HEIGHT) { + setIsCollapsed(true) + } else { + setIsCollapsed(false) + dispatch(resizeDataTable(h)) + } + }, + [dispatch] + ) + const onCountChange = useCallback((total, filtered) => { setTotalCount(total) setFilteredCount(filtered) @@ -76,11 +133,14 @@ const BottomPanel = () => { const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) useLayoutEffect(() => { + if (isDraggingRef.current) { + return + } document.documentElement.style.setProperty( '--data-table-height', - `${tableHeight}px` + `${displayHeight}px` ) - }, [tableHeight]) + }, [displayHeight]) useLayoutEffect( () => () => @@ -104,6 +164,12 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) + useEffect(() => { + if (showOnlySelected && selectedCount === 0) { + dispatch(setShowOnlySelected(false)) + } + }, [dispatch, showOnlySelected, selectedCount]) + const rowCountLabel = useMemo(() => { if (totalCount === null || filteredCount === null) { return null @@ -122,12 +188,26 @@ const BottomPanel = () => { className={styles.bottomPanel} data-test="bottom-panel" > -
- dispatch(resizeDataTable(height))} - /> +
+ {
, document.body )} + {rowCountLabel && ( {rowCountLabel} )} @@ -167,6 +254,40 @@ const BottomPanel = () => { )} + + + dispatch(setHighlightColor(color))} + /> + +
-
- - - -
+ {!isCollapsed && ( +
+ + + +
+ )} ) } diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 7113148b17..3da1faf1bd 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -24,6 +24,12 @@ import React, { import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' import { highlightFeature } from '../../actions/feature.js' +import { + toggleFeatureSelection, + selectAllFeatures, + selectFeatureRange, + clearSelection, +} from '../../actions/selection.js' import { isDarkColor } from '../../util/colors.js' import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' @@ -36,15 +42,37 @@ import { useTableData } from './useTableData.js' const ASCENDING = 'asc' const DESCENDING = 'desc' -// Decides whether a row's highlight should be cleared on mouse leave. -// When hovering to the next row the next element is a `TD`, in which case -// `setFeatureHighlight` fires and the highlight does not need to be cleared. -// When leaving to no element (e.g. the cursor exits the browser window) -// `relatedTarget` is null, so the optional chaining guards against a crash. -// Exported for testing. export const shouldClearFeatureHighlight = (event) => event.relatedTarget?.tagName !== 'TD' +const getRowId = (row) => + row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId + +export const getRowClickAction = ( + event, + { id, rowIndex, rows, lastClickedRowIndex } +) => { + if (event.shiftKey) { + if (lastClickedRowIndex === null) { + return { type: 'toggle', id } + } + const [start, end] = [lastClickedRowIndex, rowIndex].sort( + (a, b) => a - b + ) + const ids = rows + .slice(start, end + 1) + .map(getRowId) + .filter(Boolean) + return { type: 'range', ids } + } + + if (event.ctrlKey || event.metaKey) { + return { type: 'toggle', id } + } + + return null +} + const DataTableWithVirtuosoContext = ({ context, ...props }) => ( ( onMouseEnter={() => context.onMouseEnter(item)} onMouseLeave={context.onMouseLeave} onContextMenu={(e) => context.onContextMenu(e, item)} + onClick={(e) => context.onRowClick(item, e)} + onDoubleClick={() => context.onRowDoubleClick(item)} {...props} /> ) @@ -73,6 +103,8 @@ DataTableRowWithVirtuosoContext.propTypes = { onContextMenu: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, + onRowClick: PropTypes.func, + onRowDoubleClick: PropTypes.func, }), item: PropTypes.arrayOf( PropTypes.shape({ @@ -99,12 +131,13 @@ const TableComponents = { ), } -const Table = ({ availableWidth, onCountChange }) => { +const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { 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) @@ -112,6 +145,11 @@ const Table = ({ availableWidth, onCountChange }) => { const dispatch = useDispatch() const feature = useSelector((state) => state.feature) + const selection = useSelector((state) => state.selection) + const showOnlyFeaturesInView = useSelector( + (state) => state.ui.showOnlyFeaturesInView + ) + const mapBounds = useSelector((state) => state.ui.mapBounds) const [{ sortField, sortDirection }, setSorting] = useReducer( (sorting, newSorting) => ({ ...sorting, ...newSorting }), { @@ -137,8 +175,7 @@ const Table = ({ availableWidth, onCountChange }) => { const setFeatureHighlight = useCallback( (row) => { - const id = - row.find((r) => r.dataKey === 'id')?.value || row[0].itemId + const id = getRowId(row) if (!id || !feature || id !== feature.id) { dispatch( @@ -181,8 +218,7 @@ const Table = ({ availableWidth, onCountChange }) => { const onRowContextMenu = useCallback( (e, row) => { e.preventDefault() - const id = - row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId + const id = getRowId(row) const feature = featureById.get(id) setTableContextMenu({ x: e.clientX, @@ -193,31 +229,147 @@ const Table = ({ availableWidth, onCountChange }) => { [featureById] ) + const selectedIds = useMemo( + () => (selection.layerId === layer.id ? selection.ids : []), + [selection, layer.id] + ) + const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + + const { headers, rows, isLoading, error, totalCount, filteredCount } = + useTableData({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + showOnlySelected, + selectedIdSet, + }) + + useEffect(() => { + onCountChange?.(totalCount, filteredCount) + }, [onCountChange, totalCount, filteredCount]) + + const lastClickedRowIndexRef = useRef(null) + + const onRowClick = useCallback( + (row, event) => { + const id = getRowId(row) + + if (!id || !rows) { + return + } + + const rowIndex = rows.findIndex((r) => getRowId(r) === id) + const action = getRowClickAction(event, { + id, + rowIndex, + rows, + lastClickedRowIndex: lastClickedRowIndexRef.current, + }) + + if (!action) { + return + } + + if (action.type === 'range') { + dispatch(selectFeatureRange(action.ids, layer.id)) + } else { + dispatch(toggleFeatureSelection(action.id, layer.id)) + } + lastClickedRowIndexRef.current = rowIndex + }, + [dispatch, layer.id, rows] + ) + + const onRowDoubleClick = useCallback( + (row) => { + const id = getRowId(row) + + if (!id) { + return + } + + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + }, + [dispatch, layer.id] + ) + const tableContext = useMemo( () => ({ onMouseEnter: setFeatureHighlight, onMouseLeave: clearFeatureHighlight, onContextMenu: onRowContextMenu, + onRowClick, + onRowDoubleClick, layout: columnWidths.length > 0 ? 'fixed' : 'auto', }), [ setFeatureHighlight, clearFeatureHighlight, onRowContextMenu, + onRowClick, + onRowDoubleClick, columnWidths, ] ) - const { headers, rows, isLoading, error, totalCount, filteredCount } = - useTableData({ - layer, - sortField, - sortDirection, - }) - + const lastClickedFeature = useSelector( + (state) => state.ui.lastClickedFeature + ) + const rowsRef = useRef(rows) + rowsRef.current = rows useEffect(() => { - onCountChange?.(totalCount, filteredCount) - }, [onCountChange, totalCount, filteredCount]) + if (!lastClickedFeature || lastClickedFeature.layerId !== layer.id) { + return + } + const currentRows = rowsRef.current + if (!currentRows) { + return + } + const rowIndex = currentRows.findIndex( + (row) => getRowId(row) === lastClickedFeature.id + ) + if (rowIndex !== -1) { + virtuosoRef.current?.scrollToIndex({ + index: rowIndex, + align: 'center', + behavior: 'smooth', + }) + } + }, [lastClickedFeature, layer.id]) + + const allRowIds = useMemo( + () => 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 @@ -225,7 +377,11 @@ const Table = ({ availableWidth, onCountChange }) => { requestAnimationFrame(() => { const measuredColumnWidths = [] - for (const cell of headerRowRef.current.cells) { + const dataCells = Array.from(headerRowRef.current.cells).slice( + 1 + ) + + for (const cell of dataCells) { const rect = cell.getBoundingClientRect() measuredColumnWidths.push(Math.floor(rect.width)) } @@ -273,6 +429,7 @@ const Table = ({ availableWidth, onCountChange }) => { return ( <> { data={rows} fixedHeaderContent={() => ( + + + {headers.map(({ name, dataKey, type }, index) => ( { ))} )} - itemContent={(_, row) => - row.map(({ dataKey, value, align }) => ( - - {dataKey === 'color' - ? value?.toLowerCase() - : formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - )} - - )) - } + itemContent={(_, row) => { + const rowId = getRowId(row) + const isSelected = !!rowId && selectedIdSet.has(rowId) + const isHovered = + !!rowId && + feature?.id === rowId && + feature?.layerId === layer.id + + return ( + <> + + + rowId && + dispatch( + toggleFeatureSelection( + rowId, + layer.id + ) + ) + } + onClick={(e) => e.stopPropagation()} + /> + + {row.map(({ dataKey, value, align }) => ( + + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + + ))} + + ) + }} /> {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( @@ -364,6 +571,7 @@ const Table = ({ availableWidth, onCountChange }) => { setTableContextMenu(null)} /> @@ -372,6 +580,7 @@ const Table = ({ availableWidth, onCountChange }) => { Table.propTypes = { availableWidth: PropTypes.number, + showOnlySelected: PropTypes.bool, onCountChange: PropTypes.func, } diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx index f591aac527..13985be16c 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/ResizeHandle.jsx @@ -12,6 +12,7 @@ EMPTY_DRAG_IMAGE.src = const ResizeHandle = ({ onResize, + onResizeStart, onResizeEnd, minHeight = 50, maxHeight = 500, @@ -26,6 +27,8 @@ const ResizeHandle = ({ evt.dataTransfer.setData('text/plain', 'node') // Required to initialize dragging in Firefox + onResizeStart?.() + // https://stackoverflow.com/questions/23992091/drag-and-drop-directive-no-e-clientx-or-e-clienty-on-drag-event-in-firefox document.ondragover = onDrag } @@ -80,6 +83,7 @@ ResizeHandle.propTypes = { minHeight: PropTypes.number, onResize: PropTypes.func, onResizeEnd: PropTypes.func, + onResizeStart: PropTypes.func, } export default ResizeHandle diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx index 699a0f5a10..d8acdfacdd 100644 --- a/src/components/datatable/TableContextMenu.jsx +++ b/src/components/datatable/TableContextMenu.jsx @@ -31,7 +31,7 @@ const UNDRILLABLE_LAYERS = new Set([ GEOJSON_URL_LAYER, ]) -const TableContextMenu = ({ contextMenu, layer, onClose }) => { +const TableContextMenu = ({ contextMenu, layer, selectedIds, onClose }) => { const anchorRef = useRef() const dispatch = useDispatch() const { @@ -162,6 +162,38 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { }} /> )} + } + onClick={() => { + dispatch( + highlightFeature({ + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> + } + disabled={!selectedIds?.length} + onClick={() => { + dispatch( + highlightFeature({ + ids: selectedIds, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> @@ -176,6 +208,7 @@ TableContextMenu.propTypes = { x: PropTypes.number, y: PropTypes.number, }), + selectedIds: PropTypes.array, } export default TableContextMenu diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx index 5d18019348..e236e51833 100644 --- a/src/components/datatable/__tests__/DataTable.spec.jsx +++ b/src/components/datatable/__tests__/DataTable.spec.jsx @@ -1,4 +1,7 @@ -import { shouldClearFeatureHighlight } from '../DataTable.jsx' +import { + shouldClearFeatureHighlight, + getRowClickAction, +} from '../DataTable.jsx' // DataTable.jsx transitively imports MapApi.js (maplibre-gl), which is not // needed here and fails to load under jsdom. @@ -25,3 +28,57 @@ describe('shouldClearFeatureHighlight', () => { ).toBe(true) }) }) + +describe('getRowClickAction', () => { + const rows = [ + [{ dataKey: 'id', value: 'a', itemId: 'a' }], + [{ dataKey: 'id', value: 'b', itemId: 'b' }], + [{ dataKey: 'id', value: 'c', itemId: 'c' }], + [{ dataKey: 'id', value: 'd', itemId: 'd' }], + ] + + test('plain click is ignored', () => { + expect( + getRowClickAction( + {}, + { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } + ) + ).toBeNull() + }) + + test('ctrl-click toggles just that row', () => { + expect( + getRowClickAction( + { ctrlKey: true }, + { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null } + ) + ).toEqual({ type: 'toggle', id: 'b' }) + }) + + test('shift-click with no prior anchor falls back to a single-row toggle', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'c', rowIndex: 2, rows, lastClickedRowIndex: null } + ) + ).toEqual({ type: 'toggle', id: 'c' }) + }) + + test('shift-click with a prior anchor selects the range between them', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'd', rowIndex: 3, rows, lastClickedRowIndex: 1 } + ) + ).toEqual({ type: 'range', ids: ['b', 'c', 'd'] }) + }) + + test('shift-click range works regardless of anchor/target order', () => { + expect( + getRowClickAction( + { shiftKey: true }, + { id: 'a', rowIndex: 0, rows, lastClickedRowIndex: 2 } + ) + ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] }) + }) +}) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index aa8d588656..c883914a57 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -838,3 +838,141 @@ describe('useTableData sorting', () => { expect(valueColumn).toEqual([null, null, null]) }) }) + +describe('useTableData showOnlyFeaturesInView', () => { + const store = { aggregations: {} } + const bounds = [-10, -10, 10, 10] + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { + id: 'inview', + properties: { id: 'inview', name: 'In view' }, + geometry: { type: 'Point', coordinates: [0, 0] }, + }, + { + id: 'outofview', + properties: { id: 'outofview', name: 'Out of view' }, + geometry: { type: 'Point', coordinates: [50, 50] }, + }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + {children} + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: false, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(2) + }) + + test('excludes features outside the current map bounds when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) + + test('excludes features without geometry when the toggle is on', () => { + const layerWithoutCoords = { + ...layer, + data: [layer.data[0]], + dataWithoutCoords: [ + { + id: 'nogeom', + properties: { id: 'nogeom', name: 'No geometry' }, + geometry: null, + }, + ], + } + + const { current } = renderTableData({ + layer: layerWithoutCoords, + sortField: 'name', + sortDirection: 'asc', + showOnlyFeaturesInView: true, + mapBounds: bounds, + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'In view' + ) + }) +}) + +describe('useTableData showOnlySelected', () => { + const store = { aggregations: {} } + + const layer = { + id: 'test-layer', + layer: 'orgUnit', + dataFilters: null, + data: [ + { id: 'a', properties: { id: 'a', name: 'Item A' } }, + { id: 'b', properties: { id: 'b', name: 'Item B' } }, + ], + } + + const renderTableData = (props) => + renderHook(() => useTableData(props), { + wrapper: ({ children }) => ( + {children} + ), + }).result + + test('includes all rows when the toggle is off', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: false, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(2) + }) + + test('includes only selected rows when the toggle is on', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(['a']), + }) + expect(current.rows).toHaveLength(1) + expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe( + 'Item A' + ) + }) + + test('shows no rows when the toggle is on and nothing is selected', () => { + const { current } = renderTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + showOnlySelected: true, + selectedIdSet: new Set(), + }) + expect(current.rows).toHaveLength(0) + }) +}) diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 2d5dbaffb3..fc84b87494 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -33,7 +33,7 @@ font-weight: 500; font-size: 12px; color: var(--colors-grey800); - flex: 1; + flex: 0 1 auto; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -51,6 +51,7 @@ from { clip-path: inset(0 100% 0 0); } + to { clip-path: inset(0 0% 0 0); } @@ -111,7 +112,8 @@ } .clearFiltersButton, -.closeIcon { +.closeIcon, +.toggleButton { cursor: pointer; color: var(--colors-grey800); background-color: transparent; @@ -127,7 +129,33 @@ } .clearFiltersButton:hover, -.closeIcon:hover { +.closeIcon:hover, +.toggleButton:hover { color: var(--colors-grey900); background-color: var(--colors-grey300); } + +.toggleButton.active { + color: var(--colors-blue700); + background-color: var(--colors-blue100); +} + +.toggleButton.active:hover { + background-color: var(--colors-blue200); +} + +.highlightColorPicker { + margin-bottom: 0 !important; + flex-shrink: 0; + display: flex; + align-items: center; + position: relative; + top: -1px; +} + +.highlightColorPicker label { + box-sizing: border-box; + overflow: hidden; + min-width: 18px !important; + min-height: 18px !important; +} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index 65a5b32ef8..055c0ea101 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -10,6 +10,7 @@ td.dataCell { padding-top: var(--spacers-dp8); padding-bottom: var(--spacers-dp8); font-size: 11px; + overflow-wrap: anywhere; } td.dataCell:hover { @@ -20,6 +21,24 @@ td.lightText { color: var(--colors-white); } +th.checkboxCell, +td.checkboxCell { + width: 32px; + min-width: 32px; + max-width: 32px; + text-align: center; + padding: 0; +} + +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) { justify-content: space-between; } diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/styles/ResizeHandle.module.css index 27c0465abe..2bec5229a8 100644 --- a/src/components/datatable/styles/ResizeHandle.module.css +++ b/src/components/datatable/styles/ResizeHandle.module.css @@ -2,7 +2,8 @@ display: flex; justify-content: center; align-items: center; - width: 100%; + flex: 1 1 auto; + min-width: 24px; height: 100%; z-index: 1500; cursor: grab; diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 12d8ebe2b7..55389a70ad 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -12,7 +12,7 @@ import { import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterData } from '../../util/filter.js' -import { getGeojsonDisplayData } from '../../util/geojson.js' +import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js' import { parseRange } from '../../util/legend.js' import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' import { isValidUid } from '../../util/uid.js' @@ -197,7 +197,15 @@ const getGeoJsonUrlHeaders = (firstDataItem) => const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} -export const useTableData = ({ layer, sortField, sortDirection }) => { +export const useTableData = ({ + layer, + sortField, + sortDirection, + showOnlyFeaturesInView, + mapBounds, + showOnlySelected, + selectedIdSet, +}) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS @@ -216,6 +224,8 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { serverCluster, } = layer || EMPTY_LAYER + const boundsDependency = showOnlyFeaturesInView ? mapBounds : null + const dataWithAggregations = useMemo(() => { errorCode.current = null if (serverCluster) { @@ -232,20 +242,34 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { return null } + const inViewData = showOnlyFeaturesInView + ? allData.filter((d) => isFeatureInBounds(d, mapBounds)) + : allData + if (layerType === GEOJSON_URL_LAYER) { - return allData.map((d) => ({ + return inViewData.map((d) => ({ ...d.properties, })) } - return allData + return inViewData .filter((d) => !d.properties.hasAdditionalGeometry) .map((d, index) => ({ ...(d.properties || d), ...aggregations[d.id], index, })) - }, [data, dataWithoutCoords, aggregations, serverCluster, layerType]) + // boundsDependency intentionally proxies mapBounds only while the toggle is on + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + data, + dataWithoutCoords, + aggregations, + serverCluster, + layerType, + showOnlyFeaturesInView, + boundsDependency, + ]) const headers = useMemo(() => { if (errorCode.current) { @@ -321,7 +345,13 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { return null } - const filteredData = filterData(dataWithAggregations, dataFilters) + let filteredData = filterData(dataWithAggregations, dataFilters) + + if (showOnlySelected) { + filteredData = filteredData.filter((item) => + selectedIdSet?.has(item.id) + ) + } //sort filteredData.sort((a, b) => { @@ -376,7 +406,15 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { } }) ) - }, [headers, dataWithAggregations, dataFilters, sortField, sortDirection]) + }, [ + headers, + dataWithAggregations, + dataFilters, + sortField, + sortDirection, + showOnlySelected, + selectedIdSet, + ]) // EE layers and event layers may be loading additional data const isLoading = diff --git a/src/components/map/ContextMenu.jsx b/src/components/map/ContextMenu.jsx index a7a4255293..3c6f923244 100644 --- a/src/components/map/ContextMenu.jsx +++ b/src/components/map/ContextMenu.jsx @@ -23,6 +23,8 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, EARTH_ENGINE_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, RENDERING_STRATEGY_SPLIT_BY_PERIOD, } from '../../constants/layers.js' import { getGeojsonFeatureProfile } from '../../util/geojson.js' @@ -43,6 +45,7 @@ const ContextMenu = (props) => { layerConfig, coordinates, earthEngineLayers, + selectedIds, position, offset, closeContextMenu, @@ -61,6 +64,9 @@ const ContextMenu = (props) => { const isSplitView = layerConfig?.renderingStrategy === RENDERING_STRATEGY_SPLIT_BY_PERIOD + const supportsProfileAndDrill = + layerType !== EVENT_LAYER && layerType !== TRACKED_ENTITY_LAYER + const left = offset[0] + position[0] const top = offset[1] + position[1] @@ -117,6 +123,21 @@ const ContextMenu = (props) => { zoom: true, }) break + case 'zoom_to_layer': + highlightFeature({ + layerId: layerConfig.id, + origin: 'map', + zoom: true, + }) + break + case 'zoom_to_selected': + highlightFeature({ + ids: selectedIds, + layerId: layerConfig.id, + origin: 'map', + zoom: true, + }) + break default: } @@ -137,7 +158,8 @@ const ContextMenu = (props) => { >
- {layerType !== FACILITY_LAYER && + {supportsProfileAndDrill && + layerType !== FACILITY_LAYER && layerType !== GEOJSON_URL_LAYER && feature && ( { /> )} - {layerType !== FACILITY_LAYER && + {supportsProfileAndDrill && + layerType !== FACILITY_LAYER && layerType !== GEOJSON_URL_LAYER && feature && ( { /> )} - {feature && ( + {supportsProfileAndDrill && feature && ( { /> )} + {feature && ( + } + onClick={() => onClick('zoom_to_layer')} + /> + )} + + } + disabled={!selectedIds.length} + onClick={() => onClick('zoom_to_selected')} + /> + {coordinates && !isSplitView && ( ({ + ({ contextMenu, map, selection }) => ({ ...contextMenu, earthEngineLayers: map.mapViews.filter( (view) => view.layer === EARTH_ENGINE_LAYER ), + selectedIds: + selection.layerId === contextMenu?.layerConfig?.id + ? selection.ids + : [], }), { closeContextMenu, diff --git a/src/components/map/Map.jsx b/src/components/map/Map.jsx index bc3571c6ed..3340801468 100644 --- a/src/components/map/Map.jsx +++ b/src/components/map/Map.jsx @@ -40,11 +40,14 @@ class Map extends Component { openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, bounds: PropTypes.array, + clickFeature: PropTypes.func, closeCoordinatePopup: PropTypes.func, controls: PropTypes.array, coordinatePopup: PropTypes.array, engine: PropTypes.object, feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, isFullscreen: PropTypes.bool, isPlugin: PropTypes.bool, latitude: PropTypes.number, @@ -53,9 +56,12 @@ class Map extends Component { longitude: PropTypes.number, nameProperty: PropTypes.string, resizeCount: PropTypes.number, + selection: PropTypes.object, setAggregations: PropTypes.func, setFeatureProfile: PropTypes.func, setMapObject: PropTypes.func, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, zoom: PropTypes.number, } @@ -176,6 +182,12 @@ class Map extends Component { nameProperty, layers, feature, + selection, + highlightFeature, + highlightColor, + showOnlySelected, + clickFeature, + toggleFeatureSelection, coordinatePopup: coordinates, closeCoordinatePopup, openContextMenu, @@ -222,6 +234,14 @@ class Map extends Component { key={config.id} index={layers.length - index} feature={highlight} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={ + toggleFeatureSelection + } openContextMenu={openContextMenu} setAggregations={setAggregations} setFeatureProfile={setFeatureProfile} diff --git a/src/components/map/MapContainer.jsx b/src/components/map/MapContainer.jsx index 3ae0ee48ec..97a5acda70 100644 --- a/src/components/map/MapContainer.jsx +++ b/src/components/map/MapContainer.jsx @@ -1,10 +1,16 @@ import PropTypes from 'prop-types' -import React from 'react' +import React, { useCallback } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setAggregations } from '../../actions/aggregations.js' -import { setFeatureProfile } from '../../actions/feature.js' +import { + highlightFeature, + setFeatureProfile, + clickFeature, +} from '../../actions/feature.js' import { openContextMenu, closeCoordinatePopup } from '../../actions/map.js' +import { toggleFeatureSelection } from '../../actions/selection.js' import useBasemapConfig from '../../hooks/useBasemapConfig.js' +import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import MapLoadingMask from './MapLoadingMask.jsx' import MapName from './MapName.jsx' import MapView from './MapView.jsx' @@ -17,10 +23,21 @@ const MapContainer = ({ resizeCount, setMap }) => { (state) => !!state.interpretation.id ) const feature = useSelector((state) => state.feature) - const { layersSorting } = useSelector((state) => state.ui) + const selection = useSelector((state) => state.selection) + const { layersSorting, highlightColor, showOnlySelected } = useSelector( + (state) => state.ui + ) const basemapConfig = useBasemapConfig(basemap) const dispatch = useDispatch() + const dispatchHighlightFeature = useCallback( + (payload) => dispatch(highlightFeature(payload)), + [dispatch] + ) + const debouncedHighlightFeature = useDebouncedHighlightFeature( + dispatchHighlightFeature + ) + const loadedMapViews = mapViews.filter((layer) => layer.isLoaded) const isLoading = loadedMapViews.length !== mapViews.length @@ -33,6 +50,14 @@ const MapContainer = ({ resizeCount, setMap }) => { layers={loadedMapViews} bounds={bounds} feature={feature} + selection={selection} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + highlightFeature={debouncedHighlightFeature} + clickFeature={(payload) => dispatch(clickFeature(payload))} + toggleFeatureSelection={(id, layerId) => + dispatch(toggleFeatureSelection(id, layerId)) + } openContextMenu={(config) => dispatch(openContextMenu(config))} coordinatePopup={coordinatePopup} interpretationModalOpen={interpretationModalOpen} diff --git a/src/components/map/MapPosition.jsx b/src/components/map/MapPosition.jsx index b66119cafa..0fba1fd211 100644 --- a/src/components/map/MapPosition.jsx +++ b/src/components/map/MapPosition.jsx @@ -1,6 +1,7 @@ import cx from 'classnames' import React, { useState, useEffect, useRef } from 'react' -import { useSelector } from 'react-redux' +import { useSelector, useDispatch } from 'react-redux' +import { setMapBounds } from '../../actions/dataTable.js' import { getSplitViewLayer } from '../../util/helpers.js' import DownloadMapInfo from '../download/DownloadMapInfo.jsx' import NorthArrow from '../download/NorthArrow.jsx' @@ -13,6 +14,7 @@ const MapPosition = () => { const [map, setMap] = useState() const [resizeCount, setResizeCount] = useState(0) const outerRef = useRef(null) + const dispatch = useDispatch() const { showName, showDescription, @@ -83,6 +85,36 @@ const MapPosition = () => { } }, [map, mapId]) + // Track map bounds in Redux for the "show only features in view" data table toggle + useEffect(() => { + if (!map) { + return + } + + const mapgl = map.getMapGL() + + if (!mapgl) { + return + } + + const emitBounds = () => { + const b = mapgl.getBounds() + dispatch( + setMapBounds([ + b.getWest(), + b.getSouth(), + b.getEast(), + b.getNorth(), + ]) + ) + } + + emitBounds() + mapgl.on('moveend', emitBounds) + + return () => mapgl.off('moveend', emitBounds) + }, [map, dispatch]) + // Fit layer bounds when app mode is toggled useEffect(() => { if (map) { diff --git a/src/components/map/MapView.jsx b/src/components/map/MapView.jsx index 4cd1a60233..92a5624177 100644 --- a/src/components/map/MapView.jsx +++ b/src/components/map/MapView.jsx @@ -17,6 +17,12 @@ const MapView = (props) => { layers, controls, feature, + selection, + highlightFeature, + highlightColor, + showOnlySelected, + clickFeature, + toggleFeatureSelection, bounds, coordinatePopup, interpretationModalOpen, @@ -57,6 +63,12 @@ const MapView = (props) => { layers={splitViewLayers.reverse()} controls={mapControls} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} interpretationModalOpen={interpretationModalOpen} openContextMenu={openContextMenu} resizeCount={resizeCount} @@ -72,6 +84,12 @@ const MapView = (props) => { bounds={bounds} controls={mapControls} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} coordinatePopup={coordinatePopup} openContextMenu={openContextMenu} resizeCount={resizeCount} @@ -92,9 +110,12 @@ const MapView = (props) => { MapView.propTypes = { basemap: PropTypes.object, bounds: PropTypes.array, + clickFeature: PropTypes.func, controls: PropTypes.array, coordinatePopup: PropTypes.array, feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, interpretationModalOpen: PropTypes.bool, isFullscreen: PropTypes.bool, isPlugin: PropTypes.bool, @@ -102,7 +123,10 @@ MapView.propTypes = { layersSorting: PropTypes.bool, openContextMenu: PropTypes.func, resizeCount: PropTypes.number, + selection: PropTypes.object, setMapObject: PropTypes.func, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, } export default MapView diff --git a/src/components/map/SplitView.jsx b/src/components/map/SplitView.jsx index c3268e96a5..c6cfab5e73 100644 --- a/src/components/map/SplitView.jsx +++ b/src/components/map/SplitView.jsx @@ -13,6 +13,12 @@ const SplitView = ({ basemap, layers, feature, + selection, + highlightFeature, + highlightColor, + showOnlySelected, + clickFeature, + toggleFeatureSelection, controls, openContextMenu = Function.prototype, isFullscreen, @@ -92,6 +98,12 @@ const SplitView = ({ index={layers.length - index} externalPeriod={period} feature={feature} + selection={selection} + highlightFeature={highlightFeature} + highlightColor={highlightColor} + showOnlySelected={showOnlySelected} + clickFeature={clickFeature} + toggleFeatureSelection={toggleFeatureSelection} openContextMenu={openContextMenu} /> ) @@ -113,14 +125,20 @@ SplitView.propTypes = { ).isRequired, openContextMenu: PropTypes.func.isRequired, basemap: PropTypes.object, + clickFeature: PropTypes.func, controls: PropTypes.array, feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, interpretationModalOpen: PropTypes.bool, isFullscreen: PropTypes.bool, isPlugin: PropTypes.bool, layersSorting: PropTypes.bool, resizeCount: PropTypes.number, + selection: PropTypes.object, setMapObject: PropTypes.func, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, } export default SplitView diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index b132e09e74..8b392414c6 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -129,6 +129,9 @@ class EventLayer extends Layer { countColor, radius, onClick: this.onEventClick.bind(this), + onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), ...(styleDataItem && { hoverLabel: LABEL_TEMPLATE_TOOLTIP_ONLY }), ...(labelDataItem && labels && { @@ -267,8 +270,14 @@ class EventLayer extends Layer { ) : null } - onEventClick({ feature, coordinates }) { - this.setState({ popup: { feature, coordinates } }) + onEventClick(evt) { + const { feature, coordinates } = evt + + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: { feature, coordinates } }) + } } onPopupClose = () => { diff --git a/src/components/map/layers/FacilityLayer.jsx b/src/components/map/layers/FacilityLayer.jsx index 3ff48172b6..aadaf3aced 100644 --- a/src/components/map/layers/FacilityLayer.jsx +++ b/src/components/map/layers/FacilityLayer.jsx @@ -63,6 +63,8 @@ class FacilityLayer extends Layer { }, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), onError: this.onError.bind(this), } @@ -90,6 +92,8 @@ class FacilityLayer extends Layer { }, onClick: this.onAssociatedGeometryClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), }) } @@ -154,7 +158,11 @@ class FacilityLayer extends Layer { } onFeatureClick(evt) { - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: evt }) + } } onAssociatedGeometryClick(evt) { diff --git a/src/components/map/layers/GeoJsonLayer.js b/src/components/map/layers/GeoJsonLayer.js index 75895fac0b..864de180a2 100644 --- a/src/components/map/layers/GeoJsonLayer.js +++ b/src/components/map/layers/GeoJsonLayer.js @@ -47,6 +47,12 @@ class GeoJsonLayer extends Layer { onRightClick: isPlugin ? undefined : this.onFeatureRightClick.bind(this), + onMouseEnter: isPlugin + ? undefined + : this.onFeatureMouseEnter.bind(this), + onMouseLeave: isPlugin + ? undefined + : this.onFeatureMouseLeave.bind(this), }) map.addLayer(this.layer) @@ -59,6 +65,12 @@ class GeoJsonLayer extends Layer { onFeatureClick(evt) { const { name, keyAnalysisDigitGroupSeparator } = this.props + this.onFeatureLeftClick(evt) + + if (this.isMultiSelectClick(evt)) { + return + } + const feature = this.props.data.find( (d) => d.properties.id === evt.feature.properties.id ) diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 8464556172..ff16e13de0 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -8,6 +8,9 @@ import { DURATION_DEFAULT, } from '../../../constants/layers.js' +export const idsEqual = (a, b) => + a.length === b.length && a.every((id, i) => id === b[i]) + class Layer extends PureComponent { static contextTypes = { map: PropTypes.object, @@ -16,16 +19,22 @@ class Layer extends PureComponent { static propTypes = { id: PropTypes.string.isRequired, + clickFeature: PropTypes.func, config: PropTypes.object, data: PropTypes.array, dataFilters: PropTypes.object, editCounter: PropTypes.number, externalPeriod: PropTypes.object, // eslint-disable-line react/no-unused-prop-types feature: PropTypes.object, + highlightColor: PropTypes.string, + highlightFeature: PropTypes.func, index: PropTypes.number, isVisible: PropTypes.bool, opacity: PropTypes.number, openContextMenu: PropTypes.func, + selection: PropTypes.object, + showOnlySelected: PropTypes.bool, + toggleFeatureSelection: PropTypes.func, } static defaultProps = { @@ -51,6 +60,9 @@ class Layer extends PureComponent { editCounter, dataFilters, feature, + selection, + highlightColor, + showOnlySelected, } = this.props const { period } = this.state const { period: prevPeriod } = prevState || {} @@ -86,6 +98,43 @@ class Layer extends PureComponent { if (feature !== prevProps.feature) { this.handleFeatureUpdate(feature) + + if ( + this.getHoverId(prevProps.feature) !== this.getHoverId(feature) + ) { + this.highlightFeature() + } + } + + if ( + selection !== prevProps.selection && + !idsEqual( + this.getSelectedIds(prevProps.selection), + this.getSelectedIds(selection) + ) + ) { + this.selectFeatures() + } + + if (highlightColor !== prevProps.highlightColor) { + if (this.getHoverId()) { + this.highlightFeature() + } + if (this.getSelectedIds().length) { + this.selectFeatures() + } + } + + if ( + !idsEqual( + this.getVisibleIds( + prevProps.selection, + prevProps.showOnlySelected + ) ?? [], + this.getVisibleIds(selection, showOnlySelected) ?? [] + ) + ) { + this.updateVisibleIds() } } @@ -115,7 +164,9 @@ class Layer extends PureComponent { await this.createLayer(true) this.setLayerOrder() this.setLayerVisibility() - this.highlightFeature(this.props.feature) + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } // Override in subclass if needed @@ -185,26 +236,90 @@ class Layer extends PureComponent { } handleFeatureUpdate(feature) { - this.highlightFeature(feature) if (feature?.zoom && feature?.layerId === this.props.id) { - this.panToFeature(feature.id) + if (feature.ids?.length) { + this.panToFeature(feature.ids) + } else if (feature.id != null) { + this.panToFeature(feature.id) + } else { + this.fitBounds() + } + } + } + + getHoverId(feature = this.props.feature) { + return feature?.layerId === this.props.id ? feature.id : null + } + + getSelectedIds(selection = this.props.selection) { + return selection?.layerId === this.props.id ? selection.ids : [] + } + + highlightFeature() { + this.layer?.highlight?.(this.getHoverId(), this.props.highlightColor) + } + + selectFeatures() { + this.layer?.select?.(this.getSelectedIds(), this.props.highlightColor) + } + + getVisibleIds( + selection = this.props.selection, + showOnlySelected = this.props.showOnlySelected + ) { + if (!showOnlySelected || selection?.layerId !== this.props.id) { + return null + } + return this.getSelectedIds(selection) + } + + updateVisibleIds() { + this.layer?.setVisibleIds?.(this.getVisibleIds()) + } + + onFeatureLeftClick(evt) { + const id = evt.feature?.properties?.id + + if (!id) { + return + } + + this.props.clickFeature?.({ id, layerId: this.props.id }) + + if (this.isMultiSelectClick(evt)) { + this.props.toggleFeatureSelection?.(id, this.props.id) } } - highlightFeature(feature) { - if (this.layer?.highlight) { - this.layer.highlight(feature ? feature.id : null) + isMultiSelectClick(evt) { + return Boolean(evt.ctrlKey || evt.metaKey) + } + + onFeatureMouseEnter(evt) { + const id = evt.feature?.properties?.id + + if (id) { + this.props.highlightFeature?.({ + id, + layerId: this.props.id, + origin: 'map', + }) } } - panToFeature(featureId) { + onFeatureMouseLeave() { + this.props.highlightFeature?.(null) + } + + panToFeature(featureIds) { if (!this.layer?.getFeaturesById) { return } - const features = this.layer - .getFeaturesById(featureId) - ?.filter((f) => f.geometry) - if (!features?.length) { + const ids = Array.isArray(featureIds) ? featureIds : [featureIds] + const features = ids + .flatMap((id) => this.layer.getFeaturesById(id) ?? []) + .filter((f) => f.geometry) + if (!features.length) { return } @@ -245,6 +360,11 @@ class Layer extends PureComponent { const { left, top } = container.getBoundingClientRect() const isSplitView = renderingStrategy === RENDERING_STRATEGY_SPLIT_BY_PERIOD + const id = evt.feature?.properties?.id + + if (id) { + this.props.clickFeature?.({ id, layerId: this.props.id }) + } this.props.openContextMenu({ ...evt, diff --git a/src/components/map/layers/OrgUnitLayer.jsx b/src/components/map/layers/OrgUnitLayer.jsx index 5e12478761..d509bec16f 100644 --- a/src/components/map/layers/OrgUnitLayer.jsx +++ b/src/components/map/layers/OrgUnitLayer.jsx @@ -47,6 +47,8 @@ export default class OrgUnitLayer extends Layer { }, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } if (labels) { @@ -97,6 +99,10 @@ export default class OrgUnitLayer extends Layer { } onFeatureClick(evt) { - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: evt }) + } } } diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index 91b7abb29a..dd451d26a0 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -21,7 +21,7 @@ import { } from '../../../util/periods.js' import { poleOfInaccessibility } from '../MapApi.js' import Popup from '../Popup.jsx' -import Layer from './Layer.js' +import Layer, { idsEqual } from './Layer.js' import styles from './styles/Popup.module.css' export const ThematicLayerContext = React.createContext() @@ -67,6 +67,8 @@ class ThematicLayer extends Layer { color: noDataLegend?.color, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } if (labels) { @@ -190,20 +192,6 @@ class ThematicLayer extends Layer { return {popup && this.getPopup()} } - highlightFeature(feature) { - const { thematicMapType = THEMATIC_CHOROPLETH } = this.props - if (thematicMapType === THEMATIC_BUBBLE) { - // LayerGroup has no highlight(); delegate to each sub-layer - this.layer?._layers?.forEach((l) => { - if (l.highlight) { - l.highlight(feature ? feature.id : null) - } - }) - } else { - super.highlightFeature(feature) - } - } - componentDidUpdate(prevProps) { const prevPeriodId = prevProps.externalPeriod?.id const newPeriodId = this.props.externalPeriod?.id @@ -225,9 +213,45 @@ class ThematicLayer extends Layer { this.setLayerOpacity() this.setLayerVisibility() this.setLayerOrder() - const { feature } = this.props + const { feature, selection, highlightColor, showOnlySelected } = + this.props if (feature !== prevProps.feature) { this.handleFeatureUpdate(feature) + + if ( + this.getHoverId(prevProps.feature) !== + this.getHoverId(feature) + ) { + this.highlightFeature() + } + } + if ( + selection !== prevProps.selection && + !idsEqual( + this.getSelectedIds(prevProps.selection), + this.getSelectedIds(selection) + ) + ) { + this.selectFeatures() + } + if (highlightColor !== prevProps.highlightColor) { + if (this.getHoverId()) { + this.highlightFeature() + } + if (this.getSelectedIds().length) { + this.selectFeatures() + } + } + if ( + !idsEqual( + this.getVisibleIds( + prevProps.selection, + prevProps.showOnlySelected + ) ?? [], + this.getVisibleIds(selection, showOnlySelected) ?? [] + ) + ) { + this.updateVisibleIds() } return } @@ -248,7 +272,9 @@ class ThematicLayer extends Layer { ) { try { this.layer.setData(filteredData) - this.highlightFeature(this.props.feature) + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } catch (e) { console.warn('Failed to set layer data incrementally:', e) // fallback to full update on error @@ -285,7 +311,11 @@ class ThematicLayer extends Layer { } onFeatureClick(evt) { - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ popup: evt }) + } } buildPeriodData(props = this.props) { diff --git a/src/components/map/layers/TrackedEntityLayer.jsx b/src/components/map/layers/TrackedEntityLayer.jsx index 96f3f0841c..989c9f5573 100644 --- a/src/components/map/layers/TrackedEntityLayer.jsx +++ b/src/components/map/layers/TrackedEntityLayer.jsx @@ -86,6 +86,9 @@ class TrackedEntityLayer extends Layer { radius, }, onClick: this.onEventClick.bind(this), + onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } if (areaRadius) { @@ -119,6 +122,9 @@ class TrackedEntityLayer extends Layer { radius: relatedPointRadius || TEI_RELATED_RADIUS, }, onClick: this.onEventClickSecondary.bind(this), + onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), } const relationshipConfig = makeRelationshipLayer( @@ -157,10 +163,16 @@ class TrackedEntityLayer extends Layer { ) : null } - onEventClick({ feature, coordinates }) { - this.setState({ - popup: { feature, coordinates, activeDataSource: 'primary' }, - }) + onEventClick(evt) { + const { feature, coordinates } = evt + + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.setState({ + popup: { feature, coordinates, activeDataSource: 'primary' }, + }) + } } onEventClickSecondary({ feature, coordinates }) { this.setState({ diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 2978d561c2..35ddf16b41 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -45,7 +45,9 @@ export default class EarthEngineLayer extends Layer { await this.removeLayer() await this.createLayer(true) this.setLayerOrder() - this.highlightFeature(this.props.feature) + this.highlightFeature() + this.selectFeatures() + this.updateVisibleIds() } } @@ -116,6 +118,8 @@ export default class EarthEngineLayer extends Layer { preload: !isPlugin && this.hasAggregations(), onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), + onMouseEnter: this.onFeatureMouseEnter.bind(this), + onMouseLeave: this.onFeatureMouseLeave.bind(this), onLoad: this.onLoad.bind(this), } @@ -253,8 +257,12 @@ export default class EarthEngineLayer extends Layer { } onFeatureClick(evt) { - this.getAggregations() - this.setState({ popup: evt }) + this.onFeatureLeftClick(evt) + + if (!this.isMultiSelectClick(evt)) { + this.getAggregations() + this.setState({ popup: evt }) + } } onLoad() { diff --git a/src/components/plugin/Map.jsx b/src/components/plugin/Map.jsx index 32471e8a88..97b9c87fe0 100644 --- a/src/components/plugin/Map.jsx +++ b/src/components/plugin/Map.jsx @@ -12,6 +12,7 @@ import React, { useEffect, useRef, } from 'react' +import useDebouncedHighlightFeature from '../../hooks/useDebouncedHighlightFeature.js' import { drillUpDown } from '../../util/map.js' import { didViewsChange } from '../../util/pluginHelper.js' import MapView from '../map/MapView.jsx' @@ -55,6 +56,8 @@ const Map = forwardRef((props, ref) => { const [isFullscreen, setIsFullscreen] = useState( () => !!getFullscreenDoc().fullscreenElement ) + const [hoveredFeature, setHoveredFeature] = useState(null) + const highlightFeature = useDebouncedHighlightFeature(setHoveredFeature) const onResize = () => setResizeCount((state) => state + 1) @@ -180,6 +183,8 @@ const Map = forwardRef((props, ref) => { bounds={defaultBounds} openContextMenu={setContextMenu} resizeCount={resizeCount} + feature={hoveredFeature} + highlightFeature={highlightFeature} /> {mapViews.length > 0 && ( { + const setFeatureSpy = jest.fn() + + beforeEach(() => { + setFeatureSpy.mockClear() + }) + + it('calls setFeature immediately for a truthy payload', () => { + const { result } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy) + ) + + act(() => { + result.current({ id: 'abc' }) + }) + + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + expect(setFeatureSpy).toHaveBeenCalledWith({ id: 'abc' }) + }) + + it('debounces a null payload instead of calling setFeature immediately', () => { + jest.useFakeTimers() + const { result } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy, 100) + ) + + act(() => { + result.current(null) + }) + expect(setFeatureSpy).not.toHaveBeenCalled() + + act(() => { + jest.advanceTimersByTime(100) + }) + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + expect(setFeatureSpy).toHaveBeenCalledWith(null) + + jest.useRealTimers() + }) + + it('cancels a pending clear when a truthy payload arrives before the debounce elapses', () => { + jest.useFakeTimers() + const { result } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy, 100) + ) + + act(() => { + result.current(null) + jest.advanceTimersByTime(50) + result.current({ id: 'xyz' }) + }) + + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + expect(setFeatureSpy).toHaveBeenCalledWith({ id: 'xyz' }) + + act(() => { + jest.advanceTimersByTime(100) + }) + // The pending clear was cancelled, not just delayed further. + expect(setFeatureSpy).toHaveBeenCalledTimes(1) + + jest.useRealTimers() + }) + + it('clears the pending timeout on unmount without calling setFeature', () => { + jest.useFakeTimers() + const { result, unmount } = renderHook(() => + useDebouncedHighlightFeature(setFeatureSpy, 100) + ) + + act(() => { + result.current(null) + }) + unmount() + + act(() => { + jest.advanceTimersByTime(100) + }) + expect(setFeatureSpy).not.toHaveBeenCalled() + + jest.useRealTimers() + }) +}) diff --git a/src/hooks/useDebouncedHighlightFeature.js b/src/hooks/useDebouncedHighlightFeature.js new file mode 100644 index 0000000000..41d02711ef --- /dev/null +++ b/src/hooks/useDebouncedHighlightFeature.js @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useRef } from 'react' + +const DEFAULT_HOVER_LEAVE_DEBOUNCE_MS = 100 + +// Debounces the "clear" side of a highlightFeature dispatcher +// socontinuous mouse movement doesn't flash the highlight +const useDebouncedHighlightFeature = ( + setFeature, + debounceMs = DEFAULT_HOVER_LEAVE_DEBOUNCE_MS +) => { + const hoverLeaveTimeoutRef = useRef(null) + + const debouncedHighlightFeature = useCallback( + (payload) => { + if (hoverLeaveTimeoutRef.current) { + clearTimeout(hoverLeaveTimeoutRef.current) + hoverLeaveTimeoutRef.current = null + } + + if (payload) { + setFeature(payload) + return + } + + hoverLeaveTimeoutRef.current = setTimeout(() => { + hoverLeaveTimeoutRef.current = null + setFeature(null) + }, debounceMs) + }, + [setFeature, debounceMs] + ) + + useEffect( + () => () => { + if (hoverLeaveTimeoutRef.current) { + clearTimeout(hoverLeaveTimeoutRef.current) + } + }, + [] + ) + + return debouncedHighlightFeature +} + +export default useDebouncedHighlightFeature diff --git a/src/reducers/__tests__/selection.spec.js b/src/reducers/__tests__/selection.spec.js new file mode 100644 index 0000000000..ddb81237ae --- /dev/null +++ b/src/reducers/__tests__/selection.spec.js @@ -0,0 +1,149 @@ +import * as types from '../../constants/actionTypes.js' +import selection from '../selection.js' + +describe('selection reducer', () => { + it('returns the default state', () => { + expect(selection(undefined, {})).toEqual({ layerId: null, ids: [] }) + }) + + it('selects a single feature on a fresh layer', () => { + const state = selection(undefined, { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'a', + layerId: 'layer-1', + }) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a'] }) + }) + + it('adds a feature to the existing selection on the same layer', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'b', + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b'] }) + }) + + it('removes an already-selected feature (toggle off)', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'a', + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['b'] }) + }) + + it('replaces the selection with a fresh single id when toggling on a different layer', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.FEATURE_TOGGLE_SELECTION, + id: 'c', + layerId: 'layer-2', + } + ) + + expect(state).toEqual({ layerId: 'layer-2', ids: ['c'] }) + }) + + it('sets the full selection in one action for "select all"', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.SELECTION_SET_ALL, + ids: ['a', 'b', 'c'], + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b', 'c'] }) + }) + + it('adds a range of ids to the existing selection on the same layer (Shift+Click)', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a'] }, + { + type: types.SELECTION_ADD_RANGE, + ids: ['b', 'c', 'd'], + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b', 'c', 'd'] }) + }) + + it('dedupes ids already present when adding a range', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.SELECTION_ADD_RANGE, + ids: ['b', 'c'], + layerId: 'layer-1', + } + ) + + expect(state).toEqual({ layerId: 'layer-1', ids: ['a', 'b', 'c'] }) + }) + + it('starts a fresh selection when adding a range on a different layer', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { + type: types.SELECTION_ADD_RANGE, + ids: ['x', 'y'], + layerId: 'layer-2', + } + ) + + expect(state).toEqual({ layerId: 'layer-2', ids: ['x', 'y'] }) + }) + + it.each([ + types.SELECTION_CLEAR, + types.MAP_NEW, + types.MAP_SET, + types.DATA_TABLE_CLOSE, + types.DATA_TABLE_TOGGLE, + ])('resets to default state on %s', (type) => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { type } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + + it('resets to default state when the selected layer is removed', () => { + const state = selection( + { layerId: 'layer-1', ids: ['a', 'b'] }, + { type: types.LAYER_REMOVE, id: 'layer-1' } + ) + + expect(state).toEqual({ layerId: null, ids: [] }) + }) + + it('keeps the selection when a different layer is removed', () => { + const prevState = { layerId: 'layer-1', ids: ['a', 'b'] } + const state = selection(prevState, { + type: types.LAYER_REMOVE, + id: 'layer-2', + }) + + expect(state).toBe(prevState) + }) + + it('ignores unrelated actions', () => { + const prevState = { layerId: 'layer-1', ids: ['a'] } + + expect(selection(prevState, { type: 'UNRELATED' })).toBe(prevState) + }) +}) diff --git a/src/reducers/__tests__/ui.spec.js b/src/reducers/__tests__/ui.spec.js new file mode 100644 index 0000000000..70adf262ae --- /dev/null +++ b/src/reducers/__tests__/ui.spec.js @@ -0,0 +1,91 @@ +import * as types from '../../constants/actionTypes.js' +import ui from '../ui.js' + +describe('ui reducer — highlightColor', () => { + it('defaults to null (no color override until the user picks one)', () => { + expect(ui(undefined, {}).highlightColor).toBe(null) + }) + + it('sets a new highlight color', () => { + const state = ui(undefined, { + type: types.HIGHLIGHT_COLOR_SET, + color: '#FF0000', + }) + + expect(state.highlightColor).toBe('#FF0000') + }) + + it('leaves other state untouched', () => { + const prevState = { ...ui(undefined, {}), dataTableHeight: 400 } + const state = ui(prevState, { + type: types.HIGHLIGHT_COLOR_SET, + color: '#FF0000', + }) + + expect(state.dataTableHeight).toBe(400) + }) +}) + +describe('ui reducer — showOnlySelected', () => { + it('defaults to false', () => { + expect(ui(undefined, {}).showOnlySelected).toBe(false) + }) + + 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, + }) + + expect(state.showOnlySelected).toBe(false) + }) + + it.each([ + types.MAP_NEW, + types.MAP_SET, + types.DATA_TABLE_CLOSE, + types.DATA_TABLE_TOGGLE, + ])('resets to false on %s', (type) => { + const prevState = { ...ui(undefined, {}), showOnlySelected: true } + const state = ui(prevState, { type }) + + expect(state.showOnlySelected).toBe(false) + }) +}) + +describe('ui reducer — lastClickedFeature', () => { + it('defaults to null', () => { + expect(ui(undefined, {}).lastClickedFeature).toBe(null) + }) + + it('sets the clicked feature on MAP_FEATURE_CLICKED', () => { + const payload = { id: 'abc', layerId: 'layer-1' } + const state = ui(undefined, { + type: types.MAP_FEATURE_CLICKED, + payload, + }) + + expect(state.lastClickedFeature).toEqual(payload) + }) + + it.each([types.MAP_NEW, types.MAP_SET])('resets to null on %s', (type) => { + const prevState = { + ...ui(undefined, {}), + lastClickedFeature: { id: 'abc', layerId: 'layer-1' }, + } + const state = ui(prevState, { type }) + + expect(state.lastClickedFeature).toBe(null) + }) +}) diff --git a/src/reducers/index.js b/src/reducers/index.js index dae6948282..bcc2c09114 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -11,6 +11,7 @@ import layerEdit from './layerEdit.js' import layerSources from './layerSources.js' import map from './map.js' import orgUnitProfile from './orgUnitProfile.js' +import selection from './selection.js' import ui from './ui.js' export default combineReducers({ @@ -27,4 +28,5 @@ export default combineReducers({ ui, feature, featureProfile, + selection, }) diff --git a/src/reducers/selection.js b/src/reducers/selection.js new file mode 100644 index 0000000000..338d8a952b --- /dev/null +++ b/src/reducers/selection.js @@ -0,0 +1,49 @@ +import * as types from '../constants/actionTypes.js' + +const defaultState = { layerId: null, ids: [] } + +const selection = (state = defaultState, action) => { + switch (action.type) { + case types.FEATURE_TOGGLE_SELECTION: { + if (state.layerId !== action.layerId) { + return { layerId: action.layerId, ids: [action.id] } + } + + const alreadySelected = state.ids.includes(action.id) + + return { + layerId: action.layerId, + ids: alreadySelected + ? state.ids.filter((id) => id !== action.id) + : [...state.ids, action.id], + } + } + + case types.SELECTION_SET_ALL: + return { layerId: action.layerId, ids: action.ids } + + case types.SELECTION_ADD_RANGE: { + const ids = state.layerId === action.layerId ? state.ids : [] + + return { + layerId: action.layerId, + ids: [...new Set([...ids, ...action.ids])], + } + } + + case types.SELECTION_CLEAR: + case types.MAP_NEW: + case types.MAP_SET: + case types.DATA_TABLE_CLOSE: + case types.DATA_TABLE_TOGGLE: + return defaultState + + case types.LAYER_REMOVE: + return state.layerId === action.id ? defaultState : state + + default: + return state + } +} + +export default selection diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 42a29e3e91..b5d4ca63da 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -9,6 +9,11 @@ const defaultState = { mapContextMenu: true, downloadMode: false, layersSorting: false, + mapBounds: null, + showOnlyFeaturesInView: false, + showOnlySelected: false, + highlightColor: null, + lastClickedFeature: null, } const ui = (state = defaultState, action) => { @@ -36,11 +41,25 @@ const ui = (state = defaultState, action) => { case types.INTERPRETATIONS_PANEL_CLOSE: case types.ORGANISATION_UNIT_PROFILE_CLOSE: case types.FEATURE_PROFILE_CLOSE: + return { + ...state, + rightPanelOpen: false, + } + case types.MAP_NEW: case types.MAP_SET: return { ...state, rightPanelOpen: false, + showOnlySelected: false, + lastClickedFeature: null, + } + + case types.DATA_TABLE_CLOSE: + case types.DATA_TABLE_TOGGLE: + return { + ...state, + showOnlySelected: false, } case types.DOWNLOAD_MODE_OPEN: @@ -72,6 +91,42 @@ const ui = (state = defaultState, action) => { layersSorting: false, } + case types.MAP_BOUNDS_CHANGED: + return { + ...state, + mapBounds: action.bounds, + } + + case types.TOGGLE_SHOW_ONLY_IN_VIEW: + return { + ...state, + showOnlyFeaturesInView: !state.showOnlyFeaturesInView, + } + + case types.TOGGLE_SHOW_ONLY_SELECTED: + return { + ...state, + showOnlySelected: !state.showOnlySelected, + } + + case types.SHOW_ONLY_SELECTED_SET: + return { + ...state, + showOnlySelected: action.value, + } + + case types.HIGHLIGHT_COLOR_SET: + return { + ...state, + highlightColor: action.color, + } + + case types.MAP_FEATURE_CLICKED: + return { + ...state, + lastClickedFeature: action.payload, + } + default: return state } diff --git a/src/util/__tests__/geojson.spec.js b/src/util/__tests__/geojson.spec.js index 82247a8d98..a0c49b56ba 100644 --- a/src/util/__tests__/geojson.spec.js +++ b/src/util/__tests__/geojson.spec.js @@ -4,6 +4,8 @@ import { CENTROID_FORMAT_GEOJSON, getBounds, getCentroid, + isPointInBounds, + isFeatureInBounds, addStyleDataItem, createEventFeature, buildEventGeometryGetter, @@ -960,4 +962,140 @@ describe('geojson utils', () => { expect(getCentroid(unknown)).toBeNull() }) }) + + describe('isPointInBounds', () => { + const bounds = [-10, -10, 10, 10] + + it('returns true for a point inside the bounds', () => { + expect(isPointInBounds([0, 0], bounds)).toBe(true) + }) + + it('returns true for a point exactly on the bounds edge', () => { + expect(isPointInBounds([10, 10], bounds)).toBe(true) + expect(isPointInBounds([-10, -10], bounds)).toBe(true) + }) + + it('returns false for a point outside the bounds', () => { + expect(isPointInBounds([20, 0], bounds)).toBe(false) + expect(isPointInBounds([0, -20], bounds)).toBe(false) + }) + + describe('antimeridian-crossing bounds (west > east)', () => { + const antimeridianBounds = [170, -10, -170, 10] + + it('returns true for a point within the eastern segment', () => { + expect(isPointInBounds([175, 0], antimeridianBounds)).toBe(true) + }) + + it('returns true for a point within the western segment', () => { + expect(isPointInBounds([-175, 0], antimeridianBounds)).toBe( + true + ) + }) + + it('returns false for a point outside both segments', () => { + expect(isPointInBounds([0, 0], antimeridianBounds)).toBe(false) + }) + }) + }) + + describe('isFeatureInBounds', () => { + const bounds = [-10, -10, 10, 10] + + it('returns true for a Point feature whose coordinates fall within bounds', () => { + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [1, 2] }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(true) + }) + + it('returns false for a Point feature outside bounds', () => { + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [50, 50] }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(false) + }) + + it('returns true for a Polygon feature whose centroid falls within bounds', () => { + const feature = { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + [0, 0], + ], + ], + }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(true) + }) + + it('returns true for a large Polygon whose centroid falls outside bounds but whose shape still overlaps them', () => { + const feature = { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-100, -100], + [-5, -100], + [-5, 100], + [-100, 100], + [-100, -100], + ], + ], + }, + } + expect(getCentroid(feature.geometry)[0]).toBeLessThan(bounds[0]) + expect(isFeatureInBounds(feature, bounds)).toBe(true) + }) + + it('returns false for a Polygon whose bounding box does not overlap bounds at all', () => { + const feature = { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [50, 50], + [54, 50], + [54, 54], + [50, 54], + [50, 50], + ], + ], + }, + } + expect(isFeatureInBounds(feature, bounds)).toBe(false) + }) + + it('returns false when the feature has no geometry', () => { + expect(isFeatureInBounds({ geometry: null }, bounds)).toBe(false) + }) + + it('returns false when bounds are not provided', () => { + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [1, 2] }, + } + expect(isFeatureInBounds(feature, null)).toBe(false) + }) + + it('returns true for a Point feature within an antimeridian-crossing viewport', () => { + const antimeridianBounds = [170, -10, -170, 10] + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [175, 0] }, + } + expect(isFeatureInBounds(feature, antimeridianBounds)).toBe(true) + }) + + it('returns false for a Point feature outside an antimeridian-crossing viewport', () => { + const antimeridianBounds = [170, -10, -170, 10] + const feature = { + geometry: { type: GEO_TYPE_POINT, coordinates: [0, 0] }, + } + expect(isFeatureInBounds(feature, antimeridianBounds)).toBe(false) + }) + }) }) diff --git a/src/util/geojson.js b/src/util/geojson.js index 81be9a3a98..767c442758 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,3 +1,4 @@ +import turfBbox from '@turf/bbox' import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' import turfCentroid from '@turf/centroid' import findIndex from 'lodash/findIndex' @@ -203,6 +204,26 @@ export const getCentroid = (geometry, format = CENTROID_FORMAT_ARRAY) => { return coords } +export const isPointInBounds = ([lng, lat], [west, south, east, north]) => { + const lngInBounds = + west <= east ? lng >= west && lng <= east : lng >= west || lng <= east + return lngInBounds && lat >= south && lat <= north +} + +export const isFeatureInBounds = (feature, bounds) => { + if (!bounds || !feature.geometry) { + return false + } + const [west, south, east, north] = bounds + const [minLng, minLat, maxLng, maxLat] = turfBbox(feature.geometry) + const latOverlaps = minLat <= north && maxLat >= south + const lngOverlaps = + west <= east + ? minLng <= east && maxLng >= west + : minLng <= east || maxLng >= west + return lngOverlaps && latOverlaps +} + export const getGeojsonDisplayData = (feature) => { const { properties } = feature if (!properties) { From 52a6634e6a606fabc6c65c2326e3b142233f5fe0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 18:01:55 +0200 Subject: [PATCH 2/8] fix: bump maps-gl --- package.json | 2 +- yarn.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 83ec322d06..05dd149b49 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@dhis2/analytics": "^29.5.5", "@dhis2/app-runtime": "^3.17.3", "@dhis2/app-service-datastore": "^1.0.0-beta.3", - "@dhis2/maps-gl": "^4.4.3", + "@dhis2/maps-gl": "git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6", "@dhis2/ui": "^10.17.0", "@dnd-kit/core": "^6.0.8", "@dnd-kit/modifiers": "^9.0.0", diff --git a/yarn.lock b/yarn.lock index 90512c02d9..82fce786f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2371,10 +2371,9 @@ resolved "https://registry.yarnpkg.com/@dhis2/data-engine/-/data-engine-3.17.3.tgz#0347416e9919efbf4d9739c4141fa543f89669ad" integrity sha512-hLXt7LFrFitR7QgKfGQ3ComTLrY5IAdtERonhdo/SIrsRYWoeVaMiCOkUUzC48pEaeo1/BL5qwA7Tw7jZgROQw== -"@dhis2/maps-gl@^4.4.3": +"@dhis2/maps-gl@git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6": version "4.4.3" - resolved "https://registry.yarnpkg.com/@dhis2/maps-gl/-/maps-gl-4.4.3.tgz#5584caee92c3fd164e3ab2cb67b78a428c7d1cb3" - integrity sha512-C2aDFNV3l3v5wbo0LJHr1lydQE6St04U11kCXtzU1WGqnEMEZKzuHyEoCfhIa/gas3e7HpNVfAbL1ZHI1OqDSw== + resolved "git+https://github.com/d2-ci/maps-gl.git#e89c7e9bf5634da8b13684c314eb22838f629ed6" dependencies: "@mapbox/sphericalmercator" "^1.2.0" "@turf/area" "^7.3.5" From 182fb52cf29446fe77edb20a1dac18278d0cc2d2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 20:43:42 +0200 Subject: [PATCH 3/8] chore: sonarqube issues --- src/components/map/layers/Layer.js | 103 +++++++++------ src/components/map/layers/ThematicLayer.jsx | 132 ++++++++------------ 2 files changed, 111 insertions(+), 124 deletions(-) diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index ff16e13de0..ab8c8a13f7 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -51,61 +51,75 @@ class Layer extends PureComponent { } componentDidUpdate(prevProps, prevState = {}) { - const { - id, - data, - index, - opacity, - isVisible, - editCounter, - dataFilters, - feature, - selection, - highlightColor, - showOnlySelected, - } = this.props + this.handleDataOrPeriodChange(prevProps, prevState) + this.handleIndexChange(prevProps) + this.handleOpacityChange(prevProps) + this.handleVisibilityChange(prevProps) + this.handleFeatureChange(prevProps) + this.handleSelectionChange(prevProps) + this.handleHighlightColorChange(prevProps) + this.handleVisibleIdsChange(prevProps) + } + + // Create new map if new id of editCounter is increased + handleDataOrPeriodChange(prevProps, prevState = {}) { + const { id, data, dataFilters, editCounter } = this.props const { period } = this.state const { period: prevPeriod } = prevState || {} const isEdited = editCounter !== prevProps.editCounter - // Create new map if new id of editCounter is increased if ( - id !== prevProps.id || - data !== prevProps.data || - period?.id !== prevPeriod?.id || - dataFilters !== prevProps.dataFilters || - isEdited + id === prevProps.id && + data === prevProps.data && + period?.id === prevPeriod?.id && + dataFilters === prevProps.dataFilters && + !isEdited ) { - // Reset period if edited - if (isEdited) { - this.setPeriod(this.updateLayer.bind(this)) - } else { - this.updateLayer(dataFilters !== prevProps.dataFilters) - } + return } + // Reset period if edited + if (isEdited) { + this.setPeriod(this.updateLayer.bind(this)) + } else { + this.updateLayer(dataFilters !== prevProps.dataFilters) + } + } + + handleIndexChange(prevProps) { + const { index } = this.props if (index !== undefined && index !== prevProps.index) { this.setLayerOrder() } + } - if (opacity !== prevProps.opacity) { + handleOpacityChange(prevProps) { + if (this.props.opacity !== prevProps.opacity) { this.setLayerOpacity() } + } - if (isVisible !== prevProps.isVisible) { + handleVisibilityChange(prevProps) { + if (this.props.isVisible !== prevProps.isVisible) { this.setLayerVisibility() } + } - if (feature !== prevProps.feature) { - this.handleFeatureUpdate(feature) + handleFeatureChange(prevProps) { + const { feature } = this.props + if (feature === prevProps.feature) { + return + } - if ( - this.getHoverId(prevProps.feature) !== this.getHoverId(feature) - ) { - this.highlightFeature() - } + this.handleFeatureUpdate(feature) + + if (this.getHoverId(prevProps.feature) !== this.getHoverId(feature)) { + this.highlightFeature() } + } + handleSelectionChange(prevProps) { + const { selection } = this.props if ( selection !== prevProps.selection && !idsEqual( @@ -115,16 +129,23 @@ class Layer extends PureComponent { ) { this.selectFeatures() } + } - if (highlightColor !== prevProps.highlightColor) { - if (this.getHoverId()) { - this.highlightFeature() - } - if (this.getSelectedIds().length) { - this.selectFeatures() - } + handleHighlightColorChange(prevProps) { + if (this.props.highlightColor === prevProps.highlightColor) { + return + } + + if (this.getHoverId()) { + this.highlightFeature() } + if (this.getSelectedIds().length) { + this.selectFeatures() + } + } + handleVisibleIdsChange(prevProps) { + const { selection, showOnlySelected } = this.props if ( !idsEqual( this.getVisibleIds( diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index dd451d26a0..d9572e50cd 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -21,7 +21,7 @@ import { } from '../../../util/periods.js' import { poleOfInaccessibility } from '../MapApi.js' import Popup from '../Popup.jsx' -import Layer, { idsEqual } from './Layer.js' +import Layer from './Layer.js' import styles from './styles/Popup.module.css' export const ThematicLayerContext = React.createContext() @@ -193,73 +193,37 @@ class ThematicLayer extends Layer { } componentDidUpdate(prevProps) { + if (this.canSkipRebuild(prevProps)) { + this.handleIndexChange(prevProps) + this.handleOpacityChange(prevProps) + this.handleVisibilityChange(prevProps) + this.handleFeatureChange(prevProps) + this.handleSelectionChange(prevProps) + this.handleHighlightColorChange(prevProps) + this.handleVisibleIdsChange(prevProps) + return + } + + this.rebuildPeriodData() + this.syncPopupForNewPeriod() + } + + canSkipRebuild(prevProps) { const prevPeriodId = prevProps.externalPeriod?.id const newPeriodId = this.props.externalPeriod?.id - const dataChanged = prevProps.data !== this.props.data - const valuesChanged = - prevProps.valuesByPeriod !== this.props.valuesByPeriod - const filtersChanged = prevProps.dataFilters !== this.props.dataFilters - const renderingChanged = - prevProps.renderingStrategy !== this.props.renderingStrategy - - if ( - !dataChanged && - !valuesChanged && - !filtersChanged && - !renderingChanged && + return ( + prevProps.data === this.props.data && + prevProps.valuesByPeriod === this.props.valuesByPeriod && + prevProps.dataFilters === this.props.dataFilters && + prevProps.renderingStrategy === this.props.renderingStrategy && prevPeriodId === newPeriodId - ) { - this.setLayerOpacity() - this.setLayerVisibility() - this.setLayerOrder() - const { feature, selection, highlightColor, showOnlySelected } = - this.props - if (feature !== prevProps.feature) { - this.handleFeatureUpdate(feature) - - if ( - this.getHoverId(prevProps.feature) !== - this.getHoverId(feature) - ) { - this.highlightFeature() - } - } - if ( - selection !== prevProps.selection && - !idsEqual( - this.getSelectedIds(prevProps.selection), - this.getSelectedIds(selection) - ) - ) { - this.selectFeatures() - } - if (highlightColor !== prevProps.highlightColor) { - if (this.getHoverId()) { - this.highlightFeature() - } - if (this.getSelectedIds().length) { - this.selectFeatures() - } - } - if ( - !idsEqual( - this.getVisibleIds( - prevProps.selection, - prevProps.showOnlySelected - ) ?? [], - this.getVisibleIds(selection, showOnlySelected) ?? [] - ) - ) { - this.updateVisibleIds() - } - return - } - - const { valuesByPeriod, thematicMapType = THEMATIC_CHOROPLETH } = - this.props + ) + } - // Rebuild the period-specific data the same way as in createLayer + // Rebuild the period-specific data the same way as in createLayer + rebuildPeriodData() { + const { thematicMapType = THEMATIC_CHOROPLETH } = this.props const bubbleMap = thematicMapType === THEMATIC_BUBBLE const filteredData = this.buildPeriodData() @@ -284,30 +248,32 @@ class ThematicLayer extends Layer { // Recreate layer to pick up changes this.updateLayer() } + } - // Sync popup contents if open + // Sync popup contents if open + syncPopupForNewPeriod() { const { popup } = this.state - if (popup && this.props.externalPeriod) { - const newValues = - (valuesByPeriod && - this.props.externalPeriod && - valuesByPeriod[this.props.externalPeriod.id]) || - {} - const updatedPopup = { - ...popup, - feature: { - ...popup.feature, - properties: { - ...popup.feature.properties, - ...(newValues[popup.feature.properties.id] || { - value: i18n.t('Not set'), - }), - }, - }, - } + if (!popup || !this.props.externalPeriod) { + return + } - this.setState({ popup: updatedPopup }) + const { valuesByPeriod, externalPeriod } = this.props + const newValues = + (valuesByPeriod && valuesByPeriod[externalPeriod.id]) || {} + const updatedPopup = { + ...popup, + feature: { + ...popup.feature, + properties: { + ...popup.feature.properties, + ...(newValues[popup.feature.properties.id] || { + value: i18n.t('Not set'), + }), + }, + }, } + + this.setState({ popup: updatedPopup }) } onFeatureClick(evt) { From 3d819a401cb23207680892ae5e27c8e3051e631e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 20:49:32 +0200 Subject: [PATCH 4/8] chore: cypress tests update --- cypress/elements/map_context_menu.js | 4 ++ cypress/integration/dataTable.cy.js | 56 +++++++++---------- .../integration/layers/thematiclayer.cy.js | 6 ++ 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/cypress/elements/map_context_menu.js b/cypress/elements/map_context_menu.js index 3f856a91c4..fb760ad292 100644 --- a/cypress/elements/map_context_menu.js +++ b/cypress/elements/map_context_menu.js @@ -5,6 +5,8 @@ export const DRILL_UP = 'context-menu-drill-up' export const DRILL_DOWN = 'context-menu-drill-down' export const VIEW_PROFILE = 'context-menu-view-profile' export const ZOOM_TO_FEATURE = 'context-menu-zoom-to-feature' +export const ZOOM_TO_LAYER = 'context-menu-zoom-to-layer' +export const ZOOM_TO_SELECTED = 'context-menu-zoom-to-selected' export const SHOW_LONG_LAT = 'context-menu-show-long-lat' const ALL_OPTIONS = [ @@ -12,6 +14,8 @@ const ALL_OPTIONS = [ DRILL_DOWN, VIEW_PROFILE, ZOOM_TO_FEATURE, + ZOOM_TO_LAYER, + ZOOM_TO_SELECTED, SHOW_LONG_LAT, ] diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index c40d3da3ed..6944abe354 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -80,7 +80,7 @@ describe('data table', () => { // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 11) // Filter by name cy.getByDataTest('data-table-column-filter-input-Name') @@ -94,15 +94,15 @@ describe('data table', () => { .should('have.length', 7) // confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 1, expectedContent: 'Bargbe' }) - checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Bargbe' }) + checkTableCell({ row: 6, column: 2, expectedContent: 'Upper Bambara' }) // Sort by name cy.getByDataTest('data-table-column-sort-button-Name').click() // confirm that the rows are sorted by Name descending - checkTableCell({ row: 0, column: 1, expectedContent: 'Upper Bambara' }) - checkTableCell({ row: 6, column: 1, expectedContent: 'Bargbe' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Upper Bambara' }) + checkTableCell({ row: 6, column: 2, expectedContent: 'Bargbe' }) // filter by Value (numeric) cy.getByDataTest('data-table-column-filter-input-Value') @@ -119,8 +119,8 @@ describe('data table', () => { cy.getByDataTest('data-table-column-sort-button-Value').click() // check that the rows are sorted by Value ascending - checkTableCell({ row: 0, column: 3, expectedContent: '35' }) - checkTableCell({ row: 4, column: 3, expectedContent: '76' }) + checkTableCell({ row: 0, column: 4, expectedContent: '35' }) + checkTableCell({ row: 4, column: 4, expectedContent: '76' }) // right-click a row and select "View profile" cy.getByDataTest('bottom-panel') @@ -179,7 +179,7 @@ describe('data table', () => { // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 11) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -193,8 +193,8 @@ describe('data table', () => { .type(ouName) // check that all the rows have Org unit Moyowa - checkTableCell({ row: 0, column: 1, expectedContent: ouName }) - checkTableCell({ row: 2, column: 1, expectedContent: ouName }) + checkTableCell({ row: 0, column: 2, expectedContent: ouName }) + checkTableCell({ row: 2, column: 2, expectedContent: ouName }) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') @@ -236,8 +236,8 @@ describe('data table', () => { // Confirm that the rows are sorted by Age in years ascending // (the first click on a new column always sorts ascending) - checkTableCell({ row: 0, column: 7, expectedContent: '6' }) - checkTableCell({ row: 1, column: 7, expectedContent: '32' }) + checkTableCell({ row: 0, column: 8, expectedContent: '6' }) + checkTableCell({ row: 1, column: 8, expectedContent: '32' }) // right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') @@ -295,52 +295,52 @@ describe('data table', () => { cy.getByDataTest('bottom-panel').should('be.visible') // Confirm that the sort order is initially ascending by Name - checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() // Check that first row has Tihun CHC with value 28.63 - checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 4, expectedContent: '28.63' }) // Check that row 5 has Gbamgbama CHC with value 117.98 - checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 2, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 4, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) - checkTableCell({ row: 6, column: 3, expectedContent: '' }) + checkTableCell({ row: 6, column: 4, expectedContent: '' }) // Sort descending by Value cy.getByDataTest('data-table-column-sort-button-Value').click() - checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 0, column: 2, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 4, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) + checkTableCell({ row: 5, column: 2, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 4, expectedContent: '28.63' }) - checkTableCell({ row: 6, column: 3, expectedContent: '' }) + checkTableCell({ row: 6, column: 4, expectedContent: '' }) // Sort by index (a new column, so ascending) and scroll to the top cy.getByDataTest('data-table-column-sort-button-Index').click() cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 0, expectedContent: '0' }) + checkTableCell({ row: 0, column: 1, expectedContent: '0' }) // Check that row 0 range value is empty - checkTableCell({ row: 0, column: 5, expectedContent: '' }) + checkTableCell({ row: 0, column: 6, expectedContent: '' }) // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() // Check that row 0 range value has value '0-40' - checkTableCell({ row: 0, column: 5, expectedContent: '0 – 40' }) + checkTableCell({ row: 0, column: 6, expectedContent: '0 – 40' }) // Check that row 5 range value has value '90 - 120' - checkTableCell({ row: 5, column: 5, expectedContent: '90 – 120' }) + checkTableCell({ row: 5, column: 6, expectedContent: '90 – 120' }) // Check that row 6 range value is empty - checkTableCell({ row: 6, column: 5, expectedContent: '' }) + checkTableCell({ row: 6, column: 6, expectedContent: '' }) }) }) diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js index ca4e777b07..de4e1ef8b3 100644 --- a/cypress/integration/layers/thematiclayer.cy.js +++ b/cypress/integration/layers/thematiclayer.cy.js @@ -4,6 +4,8 @@ import { DRILL_DOWN, VIEW_PROFILE, ZOOM_TO_FEATURE, + ZOOM_TO_LAYER, + ZOOM_TO_SELECTED, SHOW_LONG_LAT, expectContextMenuOptions, } from '../../elements/map_context_menu.js' @@ -541,6 +543,8 @@ context('Thematic Layers', () => { { name: DRILL_DOWN }, { name: VIEW_PROFILE }, { name: ZOOM_TO_FEATURE }, + { name: ZOOM_TO_LAYER }, + { name: ZOOM_TO_SELECTED, disabled: true }, { name: SHOW_LONG_LAT }, ]) }) @@ -726,6 +730,8 @@ context('Thematic Layers', () => { { name: DRILL_DOWN }, { name: VIEW_PROFILE }, { name: ZOOM_TO_FEATURE }, + { name: ZOOM_TO_LAYER }, + { name: ZOOM_TO_SELECTED, disabled: true }, ]) }) From 76ed700172e8b63837cd170dba68cf78312ca1f4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 13 Jul 2026 20:52:37 +0200 Subject: [PATCH 5/8] chore: sonarqube issues --- src/components/map/layers/ThematicLayer.jsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index d9572e50cd..ff968b2e18 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -258,8 +258,7 @@ class ThematicLayer extends Layer { } const { valuesByPeriod, externalPeriod } = this.props - const newValues = - (valuesByPeriod && valuesByPeriod[externalPeriod.id]) || {} + const newValues = valuesByPeriod?.[externalPeriod.id] || {} const updatedPopup = { ...popup, feature: { From 2ee358e29bbaef9da96cdf0518811c86f2f31a9f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 14 Jul 2026 00:35:32 +0200 Subject: [PATCH 6/8] chore: fix cypress tests --- cypress/integration/dataTable.cy.js | 32 +++++++++++++++++++++++++- src/components/datatable/DataTable.jsx | 9 +++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 6944abe354..67e9e388ac 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -77,6 +77,9 @@ describe('data table', () => { assertMapPosition(expectedBottoms2, expectedHeights2) }) + // Collapse the Layers Panel to give the table more width + cy.getByDataTest('layers-toggle-button').click() + // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -100,6 +103,11 @@ describe('data table', () => { // Sort by name cy.getByDataTest('data-table-column-sort-button-Name').click() + // Sorting can shift the virtualized table's scroll position + // (possibly an internal react-virtuoso quirk) + // 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' }) @@ -118,6 +126,9 @@ describe('data table', () => { // Sort by value 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') + // check that the rows are sorted by Value ascending checkTableCell({ row: 0, column: 4, expectedContent: '35' }) checkTableCell({ row: 4, column: 4, expectedContent: '76' }) @@ -134,6 +145,8 @@ describe('data table', () => { // 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 cy.getByDataTest('moremenubutton').first().click() cy.getByDataTest('more-menu') @@ -176,6 +189,9 @@ describe('data table', () => { cy.getByDataTest('bottom-panel').should('be.visible') + // Collapse the Layers Panel to give the table more width + cy.getByDataTest('layers-toggle-button').click() + // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') @@ -294,12 +310,18 @@ describe('data table', () => { // Check that the bottom panel is present cy.getByDataTest('bottom-panel').should('be.visible') + // Collapse the Layers Panel to give the table more width + cy.getByDataTest('layers-toggle-button').click() + // Confirm that the sort order is initially ascending by Name checkTableCell({ row: 0, column: 2, expectedContent: 'Bendu CHC' }) // First click on a new column always sorts ascending cy.getByDataTest('data-table-column-sort-button-Value').click() + // Reset scroll position after sorting - see comment above + 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' }) @@ -314,6 +336,9 @@ describe('data table', () => { // Sort descending by Value 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: 2, expectedContent: 'Gbamgbama CHC' }) checkTableCell({ row: 0, column: 4, expectedContent: '117.98' }) @@ -322,8 +347,10 @@ describe('data table', () => { checkTableCell({ row: 6, column: 4, expectedContent: '' }) - // Sort by index (a new column, so ascending) and scroll to the top + // Sort by index (a new column, so ascending) cy.getByDataTest('data-table-column-sort-button-Index').click() + + // Reset scroll position after sorting - see comment above cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') checkTableCell({ row: 0, column: 1, expectedContent: '0' }) @@ -334,6 +361,9 @@ describe('data table', () => { // Sort by range, which is a string cy.getByDataTest('data-table-column-sort-button-Range').click() + // Reset scroll position after sorting - see comment above + 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' }) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3da1faf1bd..7fd1f0eade 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -374,7 +374,11 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { useEffect(() => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling if (columnWidths.length === 0 && headerRowRef.current) { - requestAnimationFrame(() => { + const frameId = requestAnimationFrame(() => { + if (!headerRowRef.current) { + return + } + const measuredColumnWidths = [] const dataCells = Array.from(headerRowRef.current.cells).slice( @@ -389,6 +393,8 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { minColumnWidthsRef.current = measuredColumnWidths setColumnWidths(measuredColumnWidths) }) + + return () => cancelAnimationFrame(frameId) } }, [columnWidths]) @@ -437,6 +443,7 @@ const Table = ({ availableWidth, onCountChange, showOnlySelected }) => { width: '100%', }} data={rows} + computeItemKey={(index, row) => getRowId(row) ?? index} fixedHeaderContent={() => ( Date: Mon, 27 Jul 2026 12:02:06 +0200 Subject: [PATCH 7/8] chore: sonarqube issues fix --- src/components/datatable/BottomPanel.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 6ca608b52d..f8cb939f40 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -193,6 +193,7 @@ const BottomPanel = () => { onDoubleClick={toggleCollapsed} >