From 975a235f66adef21634a3f985b64b8f5f3d435e4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:42:50 +0200 Subject: [PATCH 1/8] 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 0af38432bb..ab15e349e6 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 fe355ed6f7..06fabfb11d 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -159,6 +159,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, @@ -298,6 +309,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 bc10418347077cf44afbdf4cf4481a488fdcb502 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:50:06 +0200 Subject: [PATCH 2/8] 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 4debcb3de6..65d86e1d2a 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -365,10 +365,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 f4bc021cb62881c4bb29ee84e6a181f685c11c9d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:52:01 +0200 Subject: [PATCH 3/8] 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 26598f7999..f93754a9cf 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -89,7 +89,7 @@ const TableComponents = { ), } -const Table = ({ availableWidth }) => { +const Table = ({ availableWidth, onCountChange }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -200,11 +200,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 @@ -299,6 +304,7 @@ const Table = ({ availableWidth }) => { ? `${columnWidths[index]}px` : 'auto' } + title={name} > {name} @@ -339,6 +345,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 3402859f935544a760f01ce2f5aed7a6c86b2cd7 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 23 Jun 2026 17:59:19 +0200 Subject: [PATCH 4/8] 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 f93754a9cf..1e76374978 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -145,7 +145,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 e3a0fd7ae918aef203e703b8e4214b0983e83859 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 6 Jul 2026 19:12:30 +0200 Subject: [PATCH 5/8] feat: add toolbar, row context menu, and fix highlight persistence --- i18n/en.pot | 52 ++++-- 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, 610 insertions(+), 151 deletions(-) create mode 100644 src/components/datatable/TableContextMenu.jsx diff --git a/i18n/en.pot b/i18n/en.pot index 2794e3a2f9..009ea971e7 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-06-25T13:36:15.573Z\n" -"PO-Revision-Date: 2026-06-25T13:36:15.573Z\n" +"POT-Creation-Date: 2026-07-02T11:52:20.816Z\n" +"PO-Revision-Date: 2026-07-02T11:52:20.816Z\n" msgid "2020" msgstr "2020" @@ -158,6 +158,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" @@ -170,6 +182,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." @@ -261,9 +285,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" @@ -549,21 +570,24 @@ msgstr "the date a tracked entity was registered or enrolled in a program" msgid "Program status" msgstr "Program status" +msgid "Tracked Entity Type is required" +msgstr "Tracked Entity Type is required" + msgid "Relationships" msgstr "Relationships" msgid "Follow up" msgstr "Follow up" -msgid "Please select a Tracked Entity Type before selecting a Relationship Type" -msgstr "Please select a Tracked Entity Type before selecting a Relationship Type" - msgid "Displaying tracked entity relationships in Maps is an experimental feature" msgstr "Displaying tracked entity relationships in Maps is an experimental feature" msgid "Display Tracked Entity relationships" msgstr "Display Tracked Entity relationships" +msgid "Please select a Tracked Entity Type before selecting a Relationship Type" +msgstr "Please select a Tracked Entity Type before selecting a Relationship Type" + msgid "Tracked entity style" msgstr "Tracked entity style" @@ -576,9 +600,6 @@ msgstr "Related entity style" msgid "Line Color" msgstr "Line Color" -msgid "Tracked Entity Type is required" -msgstr "Tracked Entity Type is required" - msgid "No relationship types were found for tracked entity type {{type}}" msgstr "No relationship types were found for tracked entity type {{type}}" @@ -740,15 +761,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 5f1e78168b..605f3f2ab6 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -1,5 +1,6 @@ import turfCentroid from '@turf/centroid' import findIndex from 'lodash/findIndex' +import { formatWithSeparator } from './numbers.js' export const EVENT_ID_FIELD = 'psi' @@ -218,6 +219,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 3de8004ae6f4a04206b6493e78fb0b0219150a13 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 6 Jul 2026 19:30:34 +0200 Subject: [PATCH 6/8] 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 26b0aa53405399790bfc2c010f9803ebff13cb85 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 9 Jul 2026 19:44:12 +0200 Subject: [PATCH 7/8] 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 451537bc1c..e375f1d81d 100644 --- a/cypress/elements/map_context_menu.js +++ b/cypress/elements/map_context_menu.js @@ -3,9 +3,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 4544ff0ce0..e6b66053c3 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 427e31ccff..b63ee46bd0 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' @@ -535,6 +536,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 }) => {