diff --git a/cypress/elements/map_context_menu.js b/cypress/elements/map_context_menu.js index 451537bc1..e375f1d81 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 4544ff0ce..ec6215d1c 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,23 @@ 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' }) + // Confirm that the rows are sorted by Age in years ascending + // (the first click on a new column always sorts ascending) + checkTableCell({ row: 0, column: 7, expectedContent: '6' }) + checkTableCell({ row: 1, column: 7, expectedContent: '32' }) - // 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,41 +297,42 @@ 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() + // First click on a new column always sorts ascending + 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' }) - checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) + // Check that first row has Tihun CHC with value 28.63 + checkTableCell({ row: 0, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '28.63' }) - // Check that row 5 has Tihun CHC with value 28.63 - checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) + // Check that row 5 has Gbamgbama CHC with value 117.98 + checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) // Check that row 6 has no value (undefined) checkTableCell({ row: 6, column: 3, expectedContent: '' }) - // Sort ascending by Value - cy.get('button[title="Sort by Value"]').click() + // Sort descending by Value + 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' }) + checkTableCell({ row: 0, column: 1, expectedContent: 'Gbamgbama CHC' }) + checkTableCell({ row: 0, column: 3, expectedContent: '117.98' }) - checkTableCell({ row: 5, column: 1, expectedContent: 'Gbamgbama CHC' }) - checkTableCell({ row: 5, column: 3, expectedContent: '117.98' }) + checkTableCell({ row: 5, column: 1, expectedContent: 'Tihun CHC' }) + checkTableCell({ row: 5, column: 3, expectedContent: '28.63' }) checkTableCell({ row: 6, column: 3, expectedContent: '' }) - // Sort by index and scroll to the top - cy.get('button[title="Sort by Index"]').click() + // Sort by index (a new column, so ascending) and scroll to the top + cy.getByDataTest('data-table-column-sort-button-Index').click() cy.get('[data-testid="virtuoso-scroller"]').scrollTo('top') - checkTableCell({ row: 0, column: 0, expectedContent: '28' }) + checkTableCell({ row: 0, column: 0, expectedContent: '0' }) // Check that row 0 range value is empty 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 3cacd2590..507d0c519 100644 --- a/cypress/integration/layers/geojsonlayer.cy.js +++ b/cypress/integration/layers/geojsonlayer.cy.js @@ -76,13 +76,15 @@ 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 427e31ccf..b63ee46bd 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/i18n/en.pot b/i18n/en.pot index c3242bf6c..299eaa6ca 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." @@ -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" @@ -774,15 +795,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/actions/dataFilters.js b/src/actions/dataFilters.js index f564eb3e0..d73d4aeb9 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/components/core/icons.jsx b/src/components/core/icons.jsx index 420a19d24..b46a6354f 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 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 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') const tableHeight = dataTableHeight < maxHeight ? dataTableHeight : maxHeight + const onResize = useCallback((h) => { document.documentElement.style.setProperty( '--data-table-height', @@ -36,6 +49,31 @@ const BottomPanel = () => { ) }, []) + const onCountChange = useCallback((total, filtered) => { + setTotalCount(total) + 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 = Number.parseFloat(computed.lineHeight) + setNameTooltipPos({ + top: rect.top + (rect.height - lineHeight) / 2, + left: rect.left, + color: computed.color, + fontSize: computed.fontSize, + lineHeight: `${lineHeight}px`, + paddingLeft: computed.paddingLeft, + }) + }, []) + + const onNameMouseLeave = useCallback(() => setNameTooltipPos(null), []) + useLayoutEffect(() => { document.documentElement.style.setProperty( '--data-table-height', @@ -65,6 +103,17 @@ const BottomPanel = () => { useKeyDown('Escape', () => dispatch(closeDataTable()), true) + 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 }) + } + return (
{ 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 3d92479c3..7113148b1 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, }), @@ -98,7 +99,7 @@ const TableComponents = { ), } -const Table = ({ availableWidth }) => { +const Table = ({ availableWidth, onCountChange }) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -126,38 +127,12 @@ const Table = ({ availableWidth }) => { 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 - id && dispatch(setOrgUnitProfile(id)) - } - }, - [dispatch, layer] + [sortField, sortDirection] ) const setFeatureHighlight = useCallback( @@ -190,26 +165,59 @@ const Table = ({ availableWidth }) => { [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, ] ) - 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 @@ -278,15 +286,6 @@ const Table = ({ availableWidth }) => { { : 'auto' } > - {name} + + {name} + + + + ))} @@ -331,19 +354,25 @@ const Table = ({ availableWidth }) => { )) } /> - {isLoading && ( + {(isLoading || layer?.isLoaded === false || layer?.isLoading) && ( )} + setTableContextMenu(null)} + /> ) } Table.propTypes = { availableWidth: PropTypes.number, + onCountChange: PropTypes.func, } export default Table diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx new file mode 100644 index 000000000..79692e01c --- /dev/null +++ b/src/components/datatable/TableContextMenu.jsx @@ -0,0 +1,178 @@ +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, + Number.parseInt(level) - 1 + ) + ) + ) + onClose() + }} + /> + )} + {canDrill && ( + } + disabled={!hasCoordinatesDown} + onClick={() => { + dispatch( + updateLayer( + drillUpDown( + layer, + id, + parentGraph, + Number.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 68ce43eab..2d5dbaffb 100644 --- a/src/components/datatable/styles/BottomPanel.module.css +++ b/src/components/datatable/styles/BottomPanel.module.css @@ -18,32 +18,116 @@ .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); } -.closeIcon { +.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; +} + +@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; - top: 0; - right: 2px; - z-index: 100; + 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); - 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; } +.clearFiltersButton:hover, .closeIcon:hover { color: var(--colors-grey900); background-color: var(--colors-grey300); } - -.closeIcon svg { - position: absolute; - top: 50%; - left: 50%; - margin: -8px 0 0 -8px; -} diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css index c82cf0239..65a5b32ef 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/datatable/useTableData.js b/src/components/datatable/useTableData.js index ccbc96ab8..12d8ebe2b 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, } } diff --git a/src/components/map/ContextMenu.jsx b/src/components/map/ContextMenu.jsx index f938129dd..a7a425529 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 b837f48a0..75895fac0 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 c8bff1363..846455617 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,7 +85,7 @@ class Layer extends PureComponent { } if (feature !== prevProps.feature) { - this.highlightFeature(feature) + this.handleFeatureUpdate(feature) } } @@ -114,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 @@ -182,12 +184,54 @@ 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) + ?.filter((f) => f.geometry) + if (!features?.length) { + return + } + + const [minLng, minLat, maxLng, maxLat] = bbox({ + type: 'FeatureCollection', + features, + }) + + if (!Number.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 } diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index adf4bfbfe..91b7abb29 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 8a97a6f23..2978d561c 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/constants/actionTypes.js b/src/constants/actionTypes.js index 316484d65..6213c61b5 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 fe355ed6f..06fabfb11 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, diff --git a/src/util/geojson.js b/src/util/geojson.js index ce6b0d372..81be9a3a9 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