From e9c94800f7aec1c43c2d8a1370a1ff70e35b4956 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:42:50 +0200 Subject: [PATCH 01/12] feat: add DATA_FILTERS_CLEAR_ALL action and reducer case Adds clearDataFilters(layerId) action creator and the corresponding DATA_FILTERS_CLEAR_ALL reducer case that resets dataFilters to {} for the target layer. Used by the new "Clear filters" button in the toolbar. Co-Authored-By: Claude Sonnet 4.6 --- src/actions/dataFilters.js | 5 +++++ src/constants/actionTypes.js | 1 + src/reducers/map.js | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/src/actions/dataFilters.js b/src/actions/dataFilters.js index f564eb3e09..d73d4aeb9e 100644 --- a/src/actions/dataFilters.js +++ b/src/actions/dataFilters.js @@ -12,3 +12,8 @@ export const clearDataFilter = (layerId, fieldId) => ({ layerId, fieldId, }) + +export const clearDataFilters = (layerId) => ({ + type: types.DATA_FILTERS_CLEAR_ALL, + layerId, +}) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 04bc55e885..6bc917a61e 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -44,6 +44,7 @@ export const DATA_TABLE_RESIZE = 'DATA_TABLE_RESIZE' /* DATA FILTER */ export const DATA_FILTER_SET = 'DATA_FILTER_SET' export const DATA_FILTER_CLEAR = 'DATA_FILTER_CLEAR' +export const DATA_FILTERS_CLEAR_ALL = 'DATA_FILTERS_CLEAR_ALL' /* ORGANISATION UNITS */ export const ORGANISATION_UNIT_PROFILE_SET = 'ORGANISATION_UNIT_PROFILE_SET' diff --git a/src/reducers/map.js b/src/reducers/map.js index 7794693f07..9f7828d331 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -160,6 +160,17 @@ const layer = (state, action) => { dataFilters: filters, } + // Remove all filters for a layer + case types.DATA_FILTERS_CLEAR_ALL: + if (state.id !== action.layerId) { + return state + } + + return { + ...state, + dataFilters: {}, + } + case types.MAP_ALERTS_CLEAR: return { ...state, @@ -299,6 +310,7 @@ const map = (state = defaultState, action) => { case types.LAYER_TOGGLE_EXPAND: case types.DATA_FILTER_SET: case types.DATA_FILTER_CLEAR: + case types.DATA_FILTERS_CLEAR_ALL: case types.MAP_EARTH_ENGINE_VALUE_SHOW: return { ...state, From 2b46567e66e21e0c9e3a0a511611274c99ae74f6 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:50:06 +0200 Subject: [PATCH 02/12] feat: expose totalCount and filteredCount from useTableData Returns the total number of rows before filtering (totalCount) and after filtering (filteredCount) so the toolbar can show "X of Y rows". Co-Authored-By: Claude Sonnet 4.6 --- src/components/datatable/useTableData.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index ccbc96ab8f..12d8ebe2b7 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -385,10 +385,15 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { (!aggregations || aggregations === EMPTY_AGGREGATIONS)) || (layerType === EVENT_LAYER && !layer.isExtended && !serverCluster) + const totalCount = dataWithAggregations?.length ?? 0 + const filteredCount = rows?.length ?? 0 + return { headers, rows, isLoading, error: getErrorCodeText(errorCode.current), + totalCount, + filteredCount, } } From f8ab344e15ae72887e186139ef7e8e3087336ac4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:52:01 +0200 Subject: [PATCH 03/12] feat: redesign BottomPanel dataTableControls as 36px toolbar Replaces the 20px grey strip with a 36px toolbar that shows: - Active layer name (truncated with ellipsis) - Row count ("X of Y rows" when filtered, "Y rows" otherwise) - "Clear filters" button (only visible when filters are active) - Close button Adds onCountChange callback prop to DataTable so BottomPanel can display the post-filter row count without reading useTableData directly. Co-Authored-By: Claude Sonnet 4.6 --- src/components/datatable/BottomPanel.jsx | 51 +++++++++++++++++-- src/components/datatable/DataTable.jsx | 19 ++++--- .../datatable/styles/BottomPanel.module.css | 49 ++++++++++++------ 3 files changed, 95 insertions(+), 24 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index bbdabed720..3c16ff9a4e 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -1,4 +1,5 @@ -import { IconCross16 } from '@dhis2/ui' +import i18n from '@dhis2/d2-i18n' +import { IconCross16, Button } from '@dhis2/ui' import React, { useRef, useCallback, @@ -7,6 +8,7 @@ import React, { useLayoutEffect, } from 'react' import { useSelector, useDispatch } from 'react-redux' +import { clearDataFilters } from '../../actions/dataFilters.js' import { closeDataTable, resizeDataTable } from '../../actions/dataTable.js' import useKeyDown from '../../hooks/useKeyDown.js' import { getCssVar } from '../../util/helpers.js' @@ -16,19 +18,27 @@ import ErrorBoundary from './ErrorBoundary.jsx' import ResizeHandle from './ResizeHandle.jsx' import styles from './styles/BottomPanel.module.css' -// Container for DataTable const BottomPanel = () => { const dataTableHeight = useSelector((state) => state.ui.dataTableHeight) + const activeLayerId = useSelector((state) => state.dataTable) + const activeLayer = useSelector((state) => + state.map.mapViews.find((l) => l.id === activeLayerId) + ) + const dataFilters = activeLayer?.dataFilters ?? {} + const hasActiveFilters = Object.keys(dataFilters).length > 0 const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) const [panelWidth, setPanelWidth] = useState(0) + const [totalCount, setTotalCount] = useState(null) + const [filteredCount, setFilteredCount] = useState(null) const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight + const onResize = useCallback((h) => { document.documentElement.style.setProperty( '--data-table-height', @@ -36,6 +46,11 @@ const BottomPanel = () => { ) }, []) + const onCountChange = useCallback((total, filtered) => { + setTotalCount(total) + setFilteredCount(filtered) + }, []) + useLayoutEffect(() => { document.documentElement.style.setProperty( '--data-table-height', @@ -65,6 +80,16 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) + const rowCountLabel = + totalCount !== null && filteredCount !== null + ? filteredCount < totalCount + ? i18n.t('{{filtered}} of {{total}} rows', { + filtered: filteredCount, + total: totalCount, + }) + : i18n.t('{{total}} rows', { total: totalCount }) + : null + return (
{ onResize={onResize} onResizeEnd={(height) => dispatch(resizeDataTable(height))} /> + + {activeLayer?.name} + + {rowCountLabel && ( + {rowCountLabel} + )} + {hasActiveFilters && ( + + )}
- +
diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 3d92479c39..92c3d37ec3 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -98,7 +98,7 @@ const TableComponents = { ), } -const Table = ({ availableWidth }) => { +const Table = ({ availableWidth, onCountChange }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -205,11 +205,16 @@ const Table = ({ availableWidth }) => { ] ) - const { headers, rows, isLoading, error } = useTableData({ - layer, - sortField, - sortDirection, - }) + const { headers, rows, isLoading, error, totalCount, filteredCount } = + useTableData({ + layer, + sortField, + sortDirection, + }) + + useEffect(() => { + onCountChange?.(totalCount, filteredCount) + }, [onCountChange, totalCount, filteredCount]) useEffect(() => { // Measure column widths in auto layout, then switch to fixed to prevent content shift during virtual scrolling @@ -304,6 +309,7 @@ const Table = ({ availableWidth }) => { ? `${columnWidths[index]}px` : 'auto' } + title={name} > {name} @@ -344,6 +350,7 @@ const Table = ({ availableWidth }) => { Table.propTypes = { availableWidth: PropTypes.number, + onCountChange: PropTypes.func, } export default Table diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index 68ce43eab9..bc7d6316f5 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -18,32 +18,51 @@ .dataTableControls { width: 100%; - height: 20px; + height: 36px; background-color: var(--colors-grey100); position: relative; + display: flex; + align-items: center; + padding: 0 var(--spacers-dp4); + gap: var(--spacers-dp8); + flex-shrink: 0; + border-bottom: 1px solid var(--colors-grey300); +} + +.layerName { + font-weight: 500; + font-size: 12px; + color: var(--colors-grey800); + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; +} + +.rowCount { + font-size: 11px; + color: var(--colors-grey600); + white-space: nowrap; + flex-shrink: 0; } .closeIcon { - position: absolute; - top: 0; - right: 2px; - z-index: 100; cursor: pointer; color: var(--colors-grey800); - background-color: var(--colors-grey100); - width: 20px; - height: 20px; + background-color: transparent; + width: 24px; + height: 24px; border: none; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; } .closeIcon:hover { color: var(--colors-grey900); background-color: var(--colors-grey300); } - -.closeIcon svg { - position: absolute; - top: 50%; - left: 50%; - margin: -8px 0 0 -8px; -} From 9ef70177703a5bbb66cabb0002d2194f3a987b90 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:59:19 +0200 Subject: [PATCH 04/12] feat: zoom map to feature on data table row click Extends highlightFeature with an optional zoom:true flag. When a table row is clicked (plain click, not Ctrl+click), dispatches highlightFeature with zoom:true. Layer.js watches for this in componentDidUpdate and calls panToFeature(), which uses getFeaturesById() to compute the feature's bounding box and calls map.fitBounds() to pan and zoom to the feature. Co-Authored-By: Claude Sonnet 4.6 --- src/components/datatable/DataTable.jsx | 12 ++++++- src/components/map/layers/Layer.js | 45 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 92c3d37ec3..e607f17400 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -154,7 +154,17 @@ const Table = ({ availableWidth, onCountChange }) => { ) } else { const id = row.find((r) => r.dataKey === 'id')?.value - id && dispatch(setOrgUnitProfile(id)) + if (id) { + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + dispatch(setOrgUnitProfile(id)) + } } }, [dispatch, layer] diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index c8bff13635..535efc3b6b 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -85,6 +85,9 @@ class Layer extends PureComponent { if (feature !== prevProps.feature) { this.highlightFeature(feature) + if (feature?.zoom && feature?.layerId === this.props.id) { + this.panToFeature(feature.id) + } } } @@ -188,6 +191,48 @@ class Layer extends PureComponent { } } + panToFeature(featureId) { + if (!this.layer?.getFeaturesById) return + const features = this.layer.getFeaturesById(featureId) + if (!features?.length) return + + let minLng = Infinity, + minLat = Infinity, + maxLng = -Infinity, + maxLat = -Infinity + + const processCoords = (coords) => { + if (!coords) return + if (typeof coords[0] === 'number') { + const [lng, lat] = coords + if (lng < minLng) minLng = lng + if (lat < minLat) minLat = lat + if (lng > maxLng) maxLng = lng + if (lat > maxLat) maxLat = lat + } else { + coords.forEach(processCoords) + } + } + + features.forEach((f) => processCoords(f.geometry?.coordinates)) + + if (!isFinite(minLng)) return + + const { map } = this.context + map.fitBounds( + [ + [minLng, minLat], + [maxLng, maxLat], + ], + { + padding: PADDING_DEFAULT, + duration: DURATION_DEFAULT, + essential: true, + maxZoom: 17, + } + ) + } + render() { return null } From dafe47bb2c59cb4aa0adb5a98550737dc2b5f6d8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 6 Jul 2026 19:12:30 +0200 Subject: [PATCH 05/12] feat: add toolbar, row context menu, and fix highlight persistence --- i18n/en.pot | 36 ++-- src/components/core/icons.jsx | 49 +++++ src/components/datatable/BottomPanel.jsx | 67 ++++++- src/components/datatable/DataTable.jsx | 129 +++++++------ src/components/datatable/TableContextMenu.jsx | 174 ++++++++++++++++++ .../datatable/styles/BottomPanel.module.css | 65 +++++++ .../datatable/styles/DataTable.module.css | 30 +++ src/components/map/ContextMenu.jsx | 85 +++++++-- src/components/map/layers/GeoJsonLayer.js | 28 ++- src/components/map/layers/Layer.js | 53 +++--- src/components/map/layers/ThematicLayer.jsx | 19 ++ .../layers/earthEngine/EarthEngineLayer.jsx | 1 + src/util/geojson.js | 9 + 13 files changed, 602 insertions(+), 143 deletions(-) create mode 100644 src/components/datatable/TableContextMenu.jsx diff --git a/i18n/en.pot b/i18n/en.pot index 72d26d6fad..3f8dd50421 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -155,6 +155,18 @@ msgstr "Operator" msgid "Date" msgstr "Date" +msgid "{{filtered}} of {{total}} rows" +msgstr "{{filtered}} of {{total}} rows" + +msgid "{{total}} rows" +msgstr "{{total}} rows" + +msgid "Clear filters" +msgstr "Clear filters" + +msgid "Close" +msgstr "Close" + msgid "No results found" msgstr "No results found" @@ -167,6 +179,18 @@ msgstr "Something went wrong" msgid "Search" msgstr "Search" +msgid "Drill up one level" +msgstr "Drill up one level" + +msgid "Drill down one level" +msgstr "Drill down one level" + +msgid "View profile" +msgstr "View profile" + +msgid "Zoom to feature" +msgstr "Zoom to feature" + 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." @@ -267,9 +291,6 @@ msgstr "Map download is not supported by your browser. Try Google Chrome or Fire msgid "Cancel" msgstr "Cancel" -msgid "Close" -msgstr "Close" - msgid "No organisation units are selected" msgstr "No organisation units are selected" @@ -780,15 +801,6 @@ msgstr "Selected org units: No coordinates found" msgid "Error" msgstr "Error" -msgid "Drill up one level" -msgstr "Drill up one level" - -msgid "Drill down one level" -msgstr "Drill down one level" - -msgid "View profile" -msgstr "View profile" - msgid "Show longitude/latitude" msgstr "Show longitude/latitude" diff --git a/src/components/core/icons.jsx b/src/components/core/icons.jsx index 420a19d24a..b46a6354f5 100644 --- a/src/components/core/icons.jsx +++ b/src/components/core/icons.jsx @@ -1,5 +1,54 @@ +import PropTypes from 'prop-types' import React from 'react' +export const SortIcon = ({ direction }) => ( + + + + + + +) + +SortIcon.propTypes = { + direction: PropTypes.string, +} + +// Magnifying glass with a + sign inside the lens — "zoom to feature" +export const IconZoomIn16 = () => ( + + + +) + export const IconDrag = () => ( { const dispatch = useDispatch() const { height } = useWindowDimensions() const panelRef = useRef(null) + const nameRef = useRef(null) const [panelWidth, setPanelWidth] = useState(0) const [totalCount, setTotalCount] = useState(null) const [filteredCount, setFilteredCount] = useState(null) + const [nameTooltipPos, setNameTooltipPos] = useState(null) const maxHeight = height - getCssVar('--header-height') - getCssVar('--toolbar-height') @@ -51,6 +54,26 @@ const BottomPanel = () => { setFilteredCount(filtered) }, []) + const onNameMouseEnter = useCallback(() => { + const el = nameRef.current + if (!el || el.scrollWidth <= el.offsetWidth) { + return + } + const rect = el.getBoundingClientRect() + const computed = getComputedStyle(el) + const lineHeight = parseFloat(computed.lineHeight) + setNameTooltipPos({ + top: rect.top + (rect.height - lineHeight) / 2, + left: rect.left, + color: computed.color, + fontSize: computed.fontSize, + lineHeight: `${lineHeight}px`, + paddingLeft: computed.paddingLeft, + }) + }, []) + + const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) + useLayoutEffect(() => { document.documentElement.style.setProperty( '--data-table-height', @@ -102,28 +125,56 @@ const BottomPanel = () => { onResize={onResize} onResizeEnd={(height) => dispatch(resizeDataTable(height))} /> - + {activeLayer?.name} + {nameTooltipPos && + createPortal( +
+ {activeLayer?.name} +
, + document.body + )} {rowCountLabel && ( {rowCountLabel} )} {hasActiveFilters && ( - + + + + + + + )}
diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index e607f17400..a23047040a 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -9,6 +9,7 @@ import { ComponentCover, CenteredContent, CircularLoader, + Tooltip, } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' @@ -22,14 +23,14 @@ import React, { } from 'react' import { useSelector, useDispatch } from 'react-redux' import { TableVirtuoso } from 'react-virtuoso' -import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' -import { setOrgUnitProfile } from '../../actions/orgUnits.js' -import { EVENT_LAYER, GEOJSON_URL_LAYER } from '../../constants/layers.js' +import { highlightFeature } from '../../actions/feature.js' import { isDarkColor } from '../../util/colors.js' import { formatWithSeparator } from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import { SortIcon } from '../core/icons.jsx' import FilterInput from './FilterInput.jsx' import styles from './styles/DataTable.module.css' +import TableContextMenu from './TableContextMenu.jsx' import { useTableData } from './useTableData.js' const ASCENDING = 'asc' @@ -60,16 +61,16 @@ DataTableWithVirtuosoContext.propTypes = { const DataTableRowWithVirtuosoContext = ({ context, item, ...props }) => ( context.onClick(item)} onMouseEnter={() => context.onMouseEnter(item)} onMouseLeave={context.onMouseLeave} + onContextMenu={(e) => context.onContextMenu(e, item)} {...props} /> ) DataTableRowWithVirtuosoContext.propTypes = { context: PropTypes.shape({ - onClick: PropTypes.func, + onContextMenu: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, }), @@ -126,48 +127,12 @@ const Table = ({ availableWidth, onCountChange }) => { setSorting({ sortField: name, sortDirection: - sortDirection === ASCENDING ? DESCENDING : ASCENDING, + name === sortField && sortDirection === ASCENDING + ? DESCENDING + : ASCENDING, }) }, - [sortDirection] - ) - - const showDetailView = useCallback( - (row) => { - if (layer.layer === EVENT_LAYER) { - return - } - - if (layer.layer === GEOJSON_URL_LAYER) { - const { name } = layer - - const data = row.reduce((acc, { dataKey, value }) => { - acc[dataKey] = value - return acc - }, {}) - - dispatch( - setFeatureProfile({ - name, - data, - }) - ) - } else { - const id = row.find((r) => r.dataKey === 'id')?.value - if (id) { - dispatch( - highlightFeature({ - id, - layerId: layer.id, - origin: 'table', - zoom: true, - }) - ) - dispatch(setOrgUnitProfile(id)) - } - } - }, - [dispatch, layer] + [sortField, sortDirection] ) const setFeatureHighlight = useCallback( @@ -200,17 +165,45 @@ const Table = ({ availableWidth, onCountChange }) => { [dispatch] ) + const featureById = useMemo(() => { + const map = new Map() + layer.data?.forEach((f) => { + const id = f.properties?.id ?? f.id + if (id != null) { + map.set(id, f) + } + }) + return map + }, [layer.data]) + + const [tableContextMenu, setTableContextMenu] = useState(null) + + const onRowContextMenu = useCallback( + (e, row) => { + e.preventDefault() + const id = + row.find((r) => r.dataKey === 'id')?.value || row[0]?.itemId + const feature = featureById.get(id) + setTableContextMenu({ + x: e.clientX, + y: e.clientY, + featureProps: feature?.properties ?? { id }, + }) + }, + [featureById] + ) + const tableContext = useMemo( () => ({ - onClick: showDetailView, onMouseEnter: setFeatureHighlight, onMouseLeave: clearFeatureHighlight, + onContextMenu: onRowContextMenu, layout: columnWidths.length > 0 ? 'fixed' : 'auto', }), [ - showDetailView, setFeatureHighlight, clearFeatureHighlight, + onRowContextMenu, columnWidths, ] ) @@ -293,15 +286,6 @@ const Table = ({ availableWidth, onCountChange }) => { { ? `${columnWidths[index]}px` : 'auto' } - title={name} > - {name} + + {name} + + + + ))} @@ -347,13 +353,18 @@ const Table = ({ availableWidth, onCountChange }) => { )) } /> - {isLoading && ( + {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( )} + setTableContextMenu(null)} + /> ) } diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx new file mode 100644 index 0000000000..4256d56c67 --- /dev/null +++ b/src/components/datatable/TableContextMenu.jsx @@ -0,0 +1,174 @@ +import i18n from '@dhis2/d2-i18n' +import { + Popover, + Menu, + MenuItem, + IconArrowDown16, + IconArrowUp16, + IconInfo16, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import { useDispatch } from 'react-redux' +import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' +import { updateLayer } from '../../actions/layers.js' +import { setOrgUnitProfile } from '../../actions/orgUnits.js' +import { + BOUNDARY_LAYER, + EVENT_LAYER, + FACILITY_LAYER, + GEOJSON_URL_LAYER, +} from '../../constants/layers.js' +import { getGeojsonFeatureProfile } from '../../util/geojson.js' +import { drillUpDown } from '../../util/map.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import { IconZoomIn16 } from '../core/icons.jsx' + +const TableContextMenu = ({ contextMenu, layer, onClose }) => { + const anchorRef = useRef() + const dispatch = useDispatch() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + + if (!contextMenu) { + return null + } + + const { x, y, featureProps } = contextMenu + const layerType = layer.layer + + const { + id, + level, + hasCoordinatesUp, + hasCoordinatesDown, + grandParentId, + grandParentParentGraph, + parentGraph, + } = featureProps || {} + + const canDrill = + layerType !== BOUNDARY_LAYER && + layerType !== FACILITY_LAYER && + layerType !== EVENT_LAYER && + layerType !== GEOJSON_URL_LAYER + + const canViewProfile = id && layerType !== EVENT_LAYER + + return ( + <> +
+ + + {canDrill && ( + } + disabled={!hasCoordinatesUp} + onClick={() => { + dispatch( + updateLayer( + drillUpDown( + layer, + grandParentId, + grandParentParentGraph, + parseInt(level) - 1 + ) + ) + ) + onClose() + }} + /> + )} + {canDrill && ( + } + disabled={!hasCoordinatesDown} + onClick={() => { + dispatch( + updateLayer( + drillUpDown( + layer, + id, + parentGraph, + parseInt(level) + 1 + ) + ) + ) + onClose() + }} + /> + )} + {canViewProfile && ( + } + onClick={() => { + if (layerType === GEOJSON_URL_LAYER) { + dispatch( + setFeatureProfile( + getGeojsonFeatureProfile( + { properties: featureProps }, + layer.name, + keyAnalysisDigitGroupSeparator + ) + ) + ) + } else { + dispatch(setOrgUnitProfile(id)) + } + onClose() + }} + /> + )} + {id && ( + } + onClick={() => { + dispatch( + highlightFeature({ + id, + layerId: layer.id, + origin: 'table', + zoom: true, + }) + ) + onClose() + }} + /> + )} + + + + ) +} + +TableContextMenu.propTypes = { + layer: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired, + contextMenu: PropTypes.shape({ + featureProps: PropTypes.object, + x: PropTypes.number, + y: PropTypes.number, + }), +} + +export default TableContextMenu diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css index bc7d6316f5..2d5dbaffb3 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -47,6 +47,70 @@ flex-shrink: 0; } +@keyframes tooltipExpandRight { + from { + clip-path: inset(0 100% 0 0); + } + to { + clip-path: inset(0 0% 0 0); + } +} + +.nameTooltip { + animation: tooltipExpandRight 160ms ease-out; + background: var(--colors-white); + border-radius: 3px; + -webkit-mask-image: linear-gradient(to left, transparent, black 2em); + mask-image: linear-gradient(to left, transparent, black 2em); + padding: 0 2em 0 0; + pointer-events: none; + position: fixed; + white-space: nowrap; + z-index: 1000; +} + +.filteredIcon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.clearBadge { + position: absolute; + bottom: 0px; + right: 0px; + width: 8px; + height: 8px; + background: var(--colors-grey100); +} + +.clearFiltersButton:hover .clearBadge { + background: var(--colors-grey300); +} + +.clearBadge::before, +.clearBadge::after { + content: ''; + position: absolute; + width: 5px; + height: 1px; + background: currentColor; + top: 50%; + left: 50%; +} + +.clearBadge::before { + transform: translate(-50%, -50%) rotate(45deg); +} + +.clearBadge::after { + transform: translate(-50%, -50%) rotate(-45deg); +} + +.clearFiltersButton, .closeIcon { cursor: pointer; color: var(--colors-grey800); @@ -62,6 +126,7 @@ padding: 0; } +.clearFiltersButton:hover, .closeIcon:hover { color: var(--colors-grey900); background-color: var(--colors-grey300); diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index c82cf02395..65a5b32ef8 100644 --- a/src/components/datatable/styles/DataTable.module.css +++ b/src/components/datatable/styles/DataTable.module.css @@ -24,6 +24,36 @@ td.lightText { justify-content: space-between; } +.headerContent { + display: flex; + align-items: center; + min-width: 0; + gap: 2px; +} + +.sortButton { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + cursor: pointer; +} + +.sortButton:hover, +.sortButton:focus-visible { + background: var(--colors-grey400); +} + +.sortButton:focus { + outline: none; +} + .columnHeader :global(input.dense) { padding: 4px 6px; } diff --git a/src/components/map/ContextMenu.jsx b/src/components/map/ContextMenu.jsx index f938129dd3..a7a4255293 100644 --- a/src/components/map/ContextMenu.jsx +++ b/src/components/map/ContextMenu.jsx @@ -11,6 +11,7 @@ import { import PropTypes from 'prop-types' import React, { Fragment, useRef } from 'react' import { connect } from 'react-redux' +import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' import { updateLayer } from '../../actions/layers.js' import { closeContextMenu, @@ -20,14 +21,21 @@ import { import { setOrgUnitProfile } from '../../actions/orgUnits.js' import { FACILITY_LAYER, + GEOJSON_URL_LAYER, EARTH_ENGINE_LAYER, RENDERING_STRATEGY_SPLIT_BY_PERIOD, } from '../../constants/layers.js' +import { getGeojsonFeatureProfile } from '../../util/geojson.js' import { drillUpDown } from '../../util/map.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' +import { IconZoomIn16 } from '../core/icons.jsx' import styles from './styles/ContextMenu.module.css' const ContextMenu = (props) => { const anchorRef = useRef() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const { feature, @@ -38,9 +46,11 @@ const ContextMenu = (props) => { position, offset, closeContextMenu, + highlightFeature, openCoordinatePopup, showEarthEngineValue, setOrgUnitProfile, + setFeatureProfile, updateLayer, } = props @@ -81,7 +91,17 @@ const ContextMenu = (props) => { ) break case 'show_info': - setOrgUnitProfile(attr.id) + if (layerType === GEOJSON_URL_LAYER) { + setFeatureProfile( + getGeojsonFeatureProfile( + { properties: attr }, + layerConfig.name, + keyAnalysisDigitGroupSeparator + ) + ) + } else { + setOrgUnitProfile(attr.id) + } break case 'show_coordinate': openCoordinatePopup(coordinates) @@ -89,6 +109,14 @@ const ContextMenu = (props) => { case 'show_ee_value': showEarthEngineValue(id, coordinates) break + case 'zoom_to_feature': + highlightFeature({ + id: attr.id, + layerId: layerConfig.id, + origin: 'map', + zoom: true, + }) + break default: } @@ -109,25 +137,29 @@ const ContextMenu = (props) => { >
- {layerType !== FACILITY_LAYER && feature && ( - } - disabled={!attr.hasCoordinatesUp} - onClick={() => onClick('drill_up')} - /> - )} - - {layerType !== FACILITY_LAYER && feature && ( - } - disabled={!attr.hasCoordinatesDown} - onClick={() => onClick('drill_down')} - /> - )} + {layerType !== FACILITY_LAYER && + layerType !== GEOJSON_URL_LAYER && + feature && ( + } + disabled={!attr.hasCoordinatesUp} + onClick={() => onClick('drill_up')} + /> + )} + + {layerType !== FACILITY_LAYER && + layerType !== GEOJSON_URL_LAYER && + feature && ( + } + disabled={!attr.hasCoordinatesDown} + onClick={() => onClick('drill_down')} + /> + )} {feature && ( { /> )} + {feature && ( + } + onClick={() => onClick('zoom_to_feature')} + /> + )} + {coordinates && !isSplitView && ( { ContextMenu.propTypes = { closeContextMenu: PropTypes.func.isRequired, + highlightFeature: PropTypes.func.isRequired, openCoordinatePopup: PropTypes.func.isRequired, + setFeatureProfile: PropTypes.func.isRequired, setOrgUnitProfile: PropTypes.func.isRequired, showEarthEngineValue: PropTypes.func.isRequired, updateLayer: PropTypes.func.isRequired, @@ -192,8 +235,10 @@ export default connect( }), { closeContextMenu, + highlightFeature, openCoordinatePopup, showEarthEngineValue, + setFeatureProfile, setOrgUnitProfile, updateLayer, } diff --git a/src/components/map/layers/GeoJsonLayer.js b/src/components/map/layers/GeoJsonLayer.js index b837f48a02..75895fac0b 100644 --- a/src/components/map/layers/GeoJsonLayer.js +++ b/src/components/map/layers/GeoJsonLayer.js @@ -1,7 +1,6 @@ import { GEOJSON_LAYER } from '../../../constants/layers.js' import { filterData } from '../../../util/filter.js' -import { getGeojsonDisplayData } from '../../../util/geojson.js' -import { formatWithSeparator } from '../../../util/numbers.js' +import { getGeojsonFeatureProfile } from '../../../util/geojson.js' import Layer from './Layer.js' class GeoJsonLayer extends Layer { @@ -45,6 +44,9 @@ class GeoJsonLayer extends Layer { onClick: isPlugin ? Function.prototype : this.onFeatureClick.bind(this), + onRightClick: isPlugin + ? undefined + : this.onFeatureRightClick.bind(this), }) map.addLayer(this.layer) @@ -55,27 +57,19 @@ class GeoJsonLayer extends Layer { } onFeatureClick(evt) { - const { keyAnalysisDigitGroupSeparator } = this.props + const { name, keyAnalysisDigitGroupSeparator } = this.props const feature = this.props.data.find( (d) => d.properties.id === evt.feature.properties.id ) - const data = getGeojsonDisplayData(feature).reduce( - (acc, { dataKey, value }) => { - acc[dataKey] = formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - ) - return acc - }, - {} + this.props.setFeatureProfile( + getGeojsonFeatureProfile( + feature, + name, + keyAnalysisDigitGroupSeparator + ) ) - - this.props.setFeatureProfile({ - name: this.props.name, - data, - }) } } diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 535efc3b6b..6015e1cbcf 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -1,3 +1,4 @@ +import { bbox } from '@turf/bbox' import log from 'loglevel' import PropTypes from 'prop-types' import { PureComponent } from 'react' @@ -84,10 +85,7 @@ class Layer extends PureComponent { } if (feature !== prevProps.feature) { - this.highlightFeature(feature) - if (feature?.zoom && feature?.layerId === this.props.id) { - this.panToFeature(feature.id) - } + this.handleFeatureUpdate(feature) } } @@ -117,6 +115,7 @@ class Layer extends PureComponent { await this.createLayer(true) this.setLayerOrder() this.setLayerVisibility() + this.highlightFeature(this.props.feature) } // Override in subclass if needed @@ -185,38 +184,38 @@ class Layer extends PureComponent { } } + handleFeatureUpdate(feature) { + this.highlightFeature(feature) + if (feature?.zoom && feature?.layerId === this.props.id) { + this.panToFeature(feature.id) + } + } + highlightFeature(feature) { - if (this.layer.highlight) { + if (this.layer?.highlight) { this.layer.highlight(feature ? feature.id : null) } } panToFeature(featureId) { - if (!this.layer?.getFeaturesById) return - const features = this.layer.getFeaturesById(featureId) - if (!features?.length) return - - let minLng = Infinity, - minLat = Infinity, - maxLng = -Infinity, - maxLat = -Infinity - - const processCoords = (coords) => { - if (!coords) return - if (typeof coords[0] === 'number') { - const [lng, lat] = coords - if (lng < minLng) minLng = lng - if (lat < minLat) minLat = lat - if (lng > maxLng) maxLng = lng - if (lat > maxLat) maxLat = lat - } else { - coords.forEach(processCoords) - } + if (!this.layer?.getFeaturesById) { + return + } + const features = this.layer + .getFeaturesById(featureId) + ?.filter((f) => f.geometry) + if (!features?.length) { + return } - features.forEach((f) => processCoords(f.geometry?.coordinates)) + const [minLng, minLat, maxLng, maxLat] = bbox({ + type: 'FeatureCollection', + features, + }) - if (!isFinite(minLng)) return + if (!isFinite(minLng)) { + return + } const { map } = this.context map.fitBounds( diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index adf4bfbfe9..91b7abb29a 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -190,6 +190,20 @@ 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 @@ -211,6 +225,10 @@ class ThematicLayer extends Layer { this.setLayerOpacity() this.setLayerVisibility() this.setLayerOrder() + const { feature } = this.props + if (feature !== prevProps.feature) { + this.handleFeatureUpdate(feature) + } return } @@ -230,6 +248,7 @@ class ThematicLayer extends Layer { ) { try { this.layer.setData(filteredData) + this.highlightFeature(this.props.feature) } catch (e) { console.warn('Failed to set layer data incrementally:', e) // fallback to full update on error diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 8a97a6f230..2978d561c2 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -45,6 +45,7 @@ export default class EarthEngineLayer extends Layer { await this.removeLayer() await this.createLayer(true) this.setLayerOrder() + this.highlightFeature(this.props.feature) } } diff --git a/src/util/geojson.js b/src/util/geojson.js index ce6b0d372a..81be9a3a98 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,6 +1,7 @@ import { booleanPointInPolygon } from '@turf/boolean-point-in-polygon' import turfCentroid from '@turf/centroid' import findIndex from 'lodash/findIndex' +import { formatWithSeparator } from './numbers.js' export const EVENT_ID_FIELD = 'psi' @@ -233,6 +234,14 @@ export const getGeojsonDisplayData = (feature) => { }) } +export const getGeojsonFeatureProfile = (feature, name, separator) => ({ + name, + data: getGeojsonDisplayData(feature).reduce((acc, { dataKey, value }) => { + acc[dataKey] = formatWithSeparator(value, separator) + return acc + }, {}), +}) + // Ensure that we are always working with a FeatureCollection export const buildGeoJsonFeatures = (geoJson) => { let finalGeoJson = geoJson From c614a341cbf1ea57e58520128e540efc07e8d9ea Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 6 Jul 2026 19:30:34 +0200 Subject: [PATCH 06/12] chore: sonarqube fixes --- src/components/datatable/BottomPanel.jsx | 11 ++++++----- src/components/datatable/TableContextMenu.jsx | 4 ++-- src/components/map/layers/Layer.js | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/components/datatable/BottomPanel.jsx b/src/components/datatable/BottomPanel.jsx index 593c551814..d4ce871750 100644 --- a/src/components/datatable/BottomPanel.jsx +++ b/src/components/datatable/BottomPanel.jsx @@ -61,7 +61,7 @@ const BottomPanel = () => { } const rect = el.getBoundingClientRect() const computed = getComputedStyle(el) - const lineHeight = parseFloat(computed.lineHeight) + const lineHeight = Number.parseFloat(computed.lineHeight) setNameTooltipPos({ top: rect.top + (rect.height - lineHeight) / 2, left: rect.left, @@ -103,15 +103,16 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) - const rowCountLabel = - totalCount !== null && filteredCount !== null - ? filteredCount < totalCount + let rowCountLabel = null + if (totalCount !== null && filteredCount !== null) { + rowCountLabel = + filteredCount < totalCount ? i18n.t('{{filtered}} of {{total}} rows', { filtered: filteredCount, total: totalCount, }) : i18n.t('{{total}} rows', { total: totalCount }) - : null + } return (
{ layer, grandParentId, grandParentParentGraph, - parseInt(level) - 1 + Number.parseInt(level) - 1 ) ) ) @@ -108,7 +108,7 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => { layer, id, parentGraph, - parseInt(level) + 1 + Number.parseInt(level) + 1 ) ) ) diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 6015e1cbcf..8464556172 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -213,7 +213,7 @@ class Layer extends PureComponent { features, }) - if (!isFinite(minLng)) { + if (!Number.isFinite(minLng)) { return } From 6d139f90a61469c2072eb89e27d77775d9397dd8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 9 Jul 2026 19:44:12 +0200 Subject: [PATCH 07/12] chore: update cypress tests --- cypress/elements/map_context_menu.js | 9 +++++- cypress/integration/dataTable.cy.js | 28 +++++++++++-------- cypress/integration/layers/geojsonlayer.cy.js | 7 +++-- .../integration/layers/thematiclayer.cy.js | 3 ++ src/components/datatable/DataTable.jsx | 1 + src/components/datatable/TableContextMenu.jsx | 6 +++- 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/cypress/elements/map_context_menu.js b/cypress/elements/map_context_menu.js index 0da64b2203..3f856a91c4 100644 --- a/cypress/elements/map_context_menu.js +++ b/cypress/elements/map_context_menu.js @@ -4,9 +4,16 @@ import { getMaps } from './map_canvas.js' 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 SHOW_LONG_LAT = 'context-menu-show-long-lat' -const ALL_OPTIONS = [DRILL_UP, DRILL_DOWN, VIEW_PROFILE, SHOW_LONG_LAT] +const ALL_OPTIONS = [ + DRILL_UP, + DRILL_DOWN, + VIEW_PROFILE, + ZOOM_TO_FEATURE, + SHOW_LONG_LAT, +] export const expectContextMenuOptions = (availableOptions) => { getMaps() diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 8e2ae9afc3..637de6c56c 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -98,7 +98,7 @@ describe('data table', () => { checkTableCell({ row: 6, column: 1, expectedContent: 'Upper Bambara' }) // Sort by name - cy.get('button[title="Sort by Name"]').click() + 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' }) @@ -116,18 +116,20 @@ describe('data table', () => { .should('have.length', 5) // Sort by value - cy.get('button[title="Sort by Value"]').click() + 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' }) - // click on a row + // right-click a row and select "View profile" cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .first() - .click() + .rightclick() + + cy.getByDataTest('data-table-context-menu-view-profile').click() // check that the org unit profile drawer is opened cy.getByDataTest('org-unit-profile').should('be.visible') @@ -230,18 +232,22 @@ describe('data table', () => { .should('have.length', 2) // Sort by Age in years - cy.get('button[title="Sort by Age in years"]').click() + cy.getByDataTest('data-table-column-sort-button-Age in years').click() // confirm that the rows are sorted by Age in years descending checkTableCell({ row: 0, column: 7, expectedContent: '32' }) checkTableCell({ row: 1, column: 7, expectedContent: '6' }) - // click on a row + // right-click a row: Event layers have no profile to view cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-tablebody') .findByDataTest('dhis2-uicore-datatablerow') .first() - .click() + .rightclick() + + cy.getByDataTest('data-table-context-menu-view-profile').should( + 'not.exist' + ) // check that the org unit profile drawer is NOT opened cy.getByDataTest('org-unit-profile').should('not.exist') @@ -290,7 +296,7 @@ describe('data table', () => { // Confirm that the sort order is initially ascending by Name checkTableCell({ row: 0, column: 1, expectedContent: 'Bendu CHC' }) - cy.get('button[title="Sort by Value"]').click() + cy.getByDataTest('data-table-column-sort-button-Value').click() // Check that first row has Gbamgbama CHC with value 117.98 checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) @@ -304,7 +310,7 @@ describe('data table', () => { checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Sort ascending by Value - cy.get('button[title="Sort by Value"]').click() + cy.getByDataTest('data-table-column-sort-button-Value').click() checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) @@ -315,7 +321,7 @@ describe('data table', () => { checkTableCell({ row: 6, column: 3, expectedContent: '' }) // Sort by index and scroll to the top - cy.get('button[title="Sort by Index"]').click() + cy.getByDataTest('data-table-column-sort-button-Index').click() cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') checkTableCell({ row: 0, column: 0, expectedContent: '28' }) @@ -324,7 +330,7 @@ describe('data table', () => { checkTableCell({ row: 0, column: 5, expectedContent: '' }) // Sort by range, which is a string - cy.get('button[title="Sort by Range"]').click() + 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' }) diff --git a/cypress/integration/layers/geojsonlayer.cy.js b/cypress/integration/layers/geojsonlayer.cy.js index 3cacd25903..7aa50c44f6 100644 --- a/cypress/integration/layers/geojsonlayer.cy.js +++ b/cypress/integration/layers/geojsonlayer.cy.js @@ -76,13 +76,14 @@ describe('GeoJSON URL Layer', () => { .find('tr') .should('have.length', 1) - // open the feature panel by clicking on the row + // open the feature panel via the row context menu cy.getByDataTest('bottom-panel') .find('tbody') .find('tr') - .find('td') .first() - .click() + .rightclick() + + cy.getByDataTest('data-table-context-menu-view-profile').click() // check that Feature profile is displayed cy.getByDataTest('details-panel') diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js index c7953b05bc..ca4e777b07 100644 --- a/cypress/integration/layers/thematiclayer.cy.js +++ b/cypress/integration/layers/thematiclayer.cy.js @@ -3,6 +3,7 @@ import { DRILL_UP, DRILL_DOWN, VIEW_PROFILE, + ZOOM_TO_FEATURE, SHOW_LONG_LAT, expectContextMenuOptions, } from '../../elements/map_context_menu.js' @@ -539,6 +540,7 @@ context('Thematic Layers', () => { { name: DRILL_UP, disabled: true }, { name: DRILL_DOWN }, { name: VIEW_PROFILE }, + { name: ZOOM_TO_FEATURE }, { name: SHOW_LONG_LAT }, ]) }) @@ -723,6 +725,7 @@ context('Thematic Layers', () => { { name: DRILL_UP, disabled: true }, { name: DRILL_DOWN }, { name: VIEW_PROFILE }, + { name: ZOOM_TO_FEATURE }, ]) }) diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index a23047040a..7113148b17 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -314,6 +314,7 @@ const Table = ({ availableWidth, onCountChange }) => { )}