setTableContextMenu(null)}
/>
>
@@ -372,6 +587,7 @@ const Table = ({ availableWidth, onCountChange }) => {
Table.propTypes = {
availableWidth: PropTypes.number,
+ showOnlySelected: PropTypes.bool,
onCountChange: PropTypes.func,
}
diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx
index f591aac527..13985be16c 100644
--- a/src/components/datatable/ResizeHandle.jsx
+++ b/src/components/datatable/ResizeHandle.jsx
@@ -12,6 +12,7 @@ EMPTY_DRAG_IMAGE.src =
const ResizeHandle = ({
onResize,
+ onResizeStart,
onResizeEnd,
minHeight = 50,
maxHeight = 500,
@@ -26,6 +27,8 @@ const ResizeHandle = ({
evt.dataTransfer.setData('text/plain', 'node') // Required to initialize dragging in Firefox
+ onResizeStart?.()
+
// https://stackoverflow.com/questions/23992091/drag-and-drop-directive-no-e-clientx-or-e-clienty-on-drag-event-in-firefox
document.ondragover = onDrag
}
@@ -80,6 +83,7 @@ ResizeHandle.propTypes = {
minHeight: PropTypes.number,
onResize: PropTypes.func,
onResizeEnd: PropTypes.func,
+ onResizeStart: PropTypes.func,
}
export default ResizeHandle
diff --git a/src/components/datatable/TableContextMenu.jsx b/src/components/datatable/TableContextMenu.jsx
index 699a0f5a10..d8acdfacdd 100644
--- a/src/components/datatable/TableContextMenu.jsx
+++ b/src/components/datatable/TableContextMenu.jsx
@@ -31,7 +31,7 @@ const UNDRILLABLE_LAYERS = new Set([
GEOJSON_URL_LAYER,
])
-const TableContextMenu = ({ contextMenu, layer, onClose }) => {
+const TableContextMenu = ({ contextMenu, layer, selectedIds, onClose }) => {
const anchorRef = useRef()
const dispatch = useDispatch()
const {
@@ -162,6 +162,38 @@ const TableContextMenu = ({ contextMenu, layer, onClose }) => {
}}
/>
)}
+ }
+ onClick={() => {
+ dispatch(
+ highlightFeature({
+ layerId: layer.id,
+ origin: 'table',
+ zoom: true,
+ })
+ )
+ onClose()
+ }}
+ />
+ }
+ disabled={!selectedIds?.length}
+ onClick={() => {
+ dispatch(
+ highlightFeature({
+ ids: selectedIds,
+ layerId: layer.id,
+ origin: 'table',
+ zoom: true,
+ })
+ )
+ onClose()
+ }}
+ />
>
@@ -176,6 +208,7 @@ TableContextMenu.propTypes = {
x: PropTypes.number,
y: PropTypes.number,
}),
+ selectedIds: PropTypes.array,
}
export default TableContextMenu
diff --git a/src/components/datatable/__tests__/DataTable.spec.jsx b/src/components/datatable/__tests__/DataTable.spec.jsx
index 5d18019348..e236e51833 100644
--- a/src/components/datatable/__tests__/DataTable.spec.jsx
+++ b/src/components/datatable/__tests__/DataTable.spec.jsx
@@ -1,4 +1,7 @@
-import { shouldClearFeatureHighlight } from '../DataTable.jsx'
+import {
+ shouldClearFeatureHighlight,
+ getRowClickAction,
+} from '../DataTable.jsx'
// DataTable.jsx transitively imports MapApi.js (maplibre-gl), which is not
// needed here and fails to load under jsdom.
@@ -25,3 +28,57 @@ describe('shouldClearFeatureHighlight', () => {
).toBe(true)
})
})
+
+describe('getRowClickAction', () => {
+ const rows = [
+ [{ dataKey: 'id', value: 'a', itemId: 'a' }],
+ [{ dataKey: 'id', value: 'b', itemId: 'b' }],
+ [{ dataKey: 'id', value: 'c', itemId: 'c' }],
+ [{ dataKey: 'id', value: 'd', itemId: 'd' }],
+ ]
+
+ test('plain click is ignored', () => {
+ expect(
+ getRowClickAction(
+ {},
+ { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null }
+ )
+ ).toBeNull()
+ })
+
+ test('ctrl-click toggles just that row', () => {
+ expect(
+ getRowClickAction(
+ { ctrlKey: true },
+ { id: 'b', rowIndex: 1, rows, lastClickedRowIndex: null }
+ )
+ ).toEqual({ type: 'toggle', id: 'b' })
+ })
+
+ test('shift-click with no prior anchor falls back to a single-row toggle', () => {
+ expect(
+ getRowClickAction(
+ { shiftKey: true },
+ { id: 'c', rowIndex: 2, rows, lastClickedRowIndex: null }
+ )
+ ).toEqual({ type: 'toggle', id: 'c' })
+ })
+
+ test('shift-click with a prior anchor selects the range between them', () => {
+ expect(
+ getRowClickAction(
+ { shiftKey: true },
+ { id: 'd', rowIndex: 3, rows, lastClickedRowIndex: 1 }
+ )
+ ).toEqual({ type: 'range', ids: ['b', 'c', 'd'] })
+ })
+
+ test('shift-click range works regardless of anchor/target order', () => {
+ expect(
+ getRowClickAction(
+ { shiftKey: true },
+ { id: 'a', rowIndex: 0, rows, lastClickedRowIndex: 2 }
+ )
+ ).toEqual({ type: 'range', ids: ['a', 'b', 'c'] })
+ })
+})
diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx
index aa8d588656..c883914a57 100644
--- a/src/components/datatable/__tests__/useTableData.spec.jsx
+++ b/src/components/datatable/__tests__/useTableData.spec.jsx
@@ -838,3 +838,141 @@ describe('useTableData sorting', () => {
expect(valueColumn).toEqual([null, null, null])
})
})
+
+describe('useTableData showOnlyFeaturesInView', () => {
+ const store = { aggregations: {} }
+ const bounds = [-10, -10, 10, 10]
+
+ const layer = {
+ id: 'test-layer',
+ layer: 'orgUnit',
+ dataFilters: null,
+ data: [
+ {
+ id: 'inview',
+ properties: { id: 'inview', name: 'In view' },
+ geometry: { type: 'Point', coordinates: [0, 0] },
+ },
+ {
+ id: 'outofview',
+ properties: { id: 'outofview', name: 'Out of view' },
+ geometry: { type: 'Point', coordinates: [50, 50] },
+ },
+ ],
+ }
+
+ const renderTableData = (props) =>
+ renderHook(() => useTableData(props), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ }).result
+
+ test('includes all rows when the toggle is off', () => {
+ const { current } = renderTableData({
+ layer,
+ sortField: 'name',
+ sortDirection: 'asc',
+ showOnlyFeaturesInView: false,
+ mapBounds: bounds,
+ })
+ expect(current.rows).toHaveLength(2)
+ })
+
+ test('excludes features outside the current map bounds when the toggle is on', () => {
+ const { current } = renderTableData({
+ layer,
+ sortField: 'name',
+ sortDirection: 'asc',
+ showOnlyFeaturesInView: true,
+ mapBounds: bounds,
+ })
+ expect(current.rows).toHaveLength(1)
+ expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
+ 'In view'
+ )
+ })
+
+ test('excludes features without geometry when the toggle is on', () => {
+ const layerWithoutCoords = {
+ ...layer,
+ data: [layer.data[0]],
+ dataWithoutCoords: [
+ {
+ id: 'nogeom',
+ properties: { id: 'nogeom', name: 'No geometry' },
+ geometry: null,
+ },
+ ],
+ }
+
+ const { current } = renderTableData({
+ layer: layerWithoutCoords,
+ sortField: 'name',
+ sortDirection: 'asc',
+ showOnlyFeaturesInView: true,
+ mapBounds: bounds,
+ })
+ expect(current.rows).toHaveLength(1)
+ expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
+ 'In view'
+ )
+ })
+})
+
+describe('useTableData showOnlySelected', () => {
+ const store = { aggregations: {} }
+
+ const layer = {
+ id: 'test-layer',
+ layer: 'orgUnit',
+ dataFilters: null,
+ data: [
+ { id: 'a', properties: { id: 'a', name: 'Item A' } },
+ { id: 'b', properties: { id: 'b', name: 'Item B' } },
+ ],
+ }
+
+ const renderTableData = (props) =>
+ renderHook(() => useTableData(props), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ }).result
+
+ test('includes all rows when the toggle is off', () => {
+ const { current } = renderTableData({
+ layer,
+ sortField: 'name',
+ sortDirection: 'asc',
+ showOnlySelected: false,
+ selectedIdSet: new Set(['a']),
+ })
+ expect(current.rows).toHaveLength(2)
+ })
+
+ test('includes only selected rows when the toggle is on', () => {
+ const { current } = renderTableData({
+ layer,
+ sortField: 'name',
+ sortDirection: 'asc',
+ showOnlySelected: true,
+ selectedIdSet: new Set(['a']),
+ })
+ expect(current.rows).toHaveLength(1)
+ expect(current.rows[0].find((c) => c.dataKey === 'name').value).toBe(
+ 'Item A'
+ )
+ })
+
+ test('shows no rows when the toggle is on and nothing is selected', () => {
+ const { current } = renderTableData({
+ layer,
+ sortField: 'name',
+ sortDirection: 'asc',
+ showOnlySelected: true,
+ selectedIdSet: new Set(),
+ })
+ expect(current.rows).toHaveLength(0)
+ })
+})
diff --git a/src/components/datatable/styles/BottomPanel.module.css b/src/components/datatable/styles/BottomPanel.module.css
index 2d5dbaffb3..fc84b87494 100644
--- a/src/components/datatable/styles/BottomPanel.module.css
+++ b/src/components/datatable/styles/BottomPanel.module.css
@@ -33,7 +33,7 @@
font-weight: 500;
font-size: 12px;
color: var(--colors-grey800);
- flex: 1;
+ flex: 0 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -51,6 +51,7 @@
from {
clip-path: inset(0 100% 0 0);
}
+
to {
clip-path: inset(0 0% 0 0);
}
@@ -111,7 +112,8 @@
}
.clearFiltersButton,
-.closeIcon {
+.closeIcon,
+.toggleButton {
cursor: pointer;
color: var(--colors-grey800);
background-color: transparent;
@@ -127,7 +129,33 @@
}
.clearFiltersButton:hover,
-.closeIcon:hover {
+.closeIcon:hover,
+.toggleButton:hover {
color: var(--colors-grey900);
background-color: var(--colors-grey300);
}
+
+.toggleButton.active {
+ color: var(--colors-blue700);
+ background-color: var(--colors-blue100);
+}
+
+.toggleButton.active:hover {
+ background-color: var(--colors-blue200);
+}
+
+.highlightColorPicker {
+ margin-bottom: 0 !important;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ position: relative;
+ top: -1px;
+}
+
+.highlightColorPicker label {
+ box-sizing: border-box;
+ overflow: hidden;
+ min-width: 18px !important;
+ min-height: 18px !important;
+}
diff --git a/src/components/datatable/styles/DataTable.module.css b/src/components/datatable/styles/DataTable.module.css
index 65a5b32ef8..055c0ea101 100644
--- a/src/components/datatable/styles/DataTable.module.css
+++ b/src/components/datatable/styles/DataTable.module.css
@@ -10,6 +10,7 @@ td.dataCell {
padding-top: var(--spacers-dp8);
padding-bottom: var(--spacers-dp8);
font-size: 11px;
+ overflow-wrap: anywhere;
}
td.dataCell:hover {
@@ -20,6 +21,24 @@ td.lightText {
color: var(--colors-white);
}
+th.checkboxCell,
+td.checkboxCell {
+ width: 32px;
+ min-width: 32px;
+ max-width: 32px;
+ text-align: center;
+ padding: 0;
+}
+
+td.selected {
+ background-color: var(--colors-blue050);
+}
+
+/* Declared after .selected so a hovered and selected row still shows the hover color */
+td.hovered {
+ background-color: var(--colors-blue100);
+}
+
.columnHeader > :global(span.container) {
justify-content: space-between;
}
diff --git a/src/components/datatable/styles/ResizeHandle.module.css b/src/components/datatable/styles/ResizeHandle.module.css
index 27c0465abe..2bec5229a8 100644
--- a/src/components/datatable/styles/ResizeHandle.module.css
+++ b/src/components/datatable/styles/ResizeHandle.module.css
@@ -2,7 +2,8 @@
display: flex;
justify-content: center;
align-items: center;
- width: 100%;
+ flex: 1 1 auto;
+ min-width: 24px;
height: 100%;
z-index: 1500;
cursor: grab;
diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js
index 12d8ebe2b7..f948beeb67 100644
--- a/src/components/datatable/useTableData.js
+++ b/src/components/datatable/useTableData.js
@@ -12,7 +12,7 @@ import {
import { numberValueTypes } from '../../constants/valueTypes.js'
import { hasClasses } from '../../util/earthEngine.js'
import { filterData } from '../../util/filter.js'
-import { getGeojsonDisplayData } from '../../util/geojson.js'
+import { getGeojsonDisplayData, isFeatureInBounds } from '../../util/geojson.js'
import { parseRange } from '../../util/legend.js'
import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js'
import { isValidUid } from '../../util/uid.js'
@@ -197,7 +197,15 @@ const getGeoJsonUrlHeaders = (firstDataItem) =>
const EMPTY_AGGREGATIONS = {}
const EMPTY_LAYER = {}
-export const useTableData = ({ layer, sortField, sortDirection }) => {
+export const useTableData = ({
+ layer,
+ sortField,
+ sortDirection,
+ showOnlyFeaturesInView,
+ mapBounds,
+ showOnlySelected,
+ selectedIdSet,
+}) => {
const allAggregations = useSelector((state) => state.aggregations)
const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS
@@ -216,6 +224,10 @@ export const useTableData = ({ layer, sortField, sortDirection }) => {
serverCluster,
} = layer || EMPTY_LAYER
+ // Only depend on mapBounds while the toggle is on, so panning/zooming
+ // doesn't recompute dataWithAggregations below when it's off
+ const boundsDependency = showOnlyFeaturesInView ? mapBounds : null
+
const dataWithAggregations = useMemo(() => {
errorCode.current = null
if (serverCluster) {
@@ -232,20 +244,33 @@ export const useTableData = ({ layer, sortField, sortDirection }) => {
return null
}
+ const inViewData = showOnlyFeaturesInView
+ ? allData.filter((d) => isFeatureInBounds(d, mapBounds))
+ : allData
+
if (layerType === GEOJSON_URL_LAYER) {
- return allData.map((d) => ({
+ return inViewData.map((d) => ({
...d.properties,
}))
}
- return allData
+ return inViewData
.filter((d) => !d.properties.hasAdditionalGeometry)
.map((d, index) => ({
...(d.properties || d),
...aggregations[d.id],
index,
}))
- }, [data, dataWithoutCoords, aggregations, serverCluster, layerType])
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ data,
+ dataWithoutCoords,
+ aggregations,
+ serverCluster,
+ layerType,
+ showOnlyFeaturesInView,
+ boundsDependency,
+ ])
const headers = useMemo(() => {
if (errorCode.current) {
@@ -321,7 +346,13 @@ export const useTableData = ({ layer, sortField, sortDirection }) => {
return null
}
- const filteredData = filterData(dataWithAggregations, dataFilters)
+ let filteredData = filterData(dataWithAggregations, dataFilters)
+
+ if (showOnlySelected) {
+ filteredData = filteredData.filter((item) =>
+ selectedIdSet?.has(item.id)
+ )
+ }
//sort
filteredData.sort((a, b) => {
@@ -376,7 +407,15 @@ export const useTableData = ({ layer, sortField, sortDirection }) => {
}
})
)
- }, [headers, dataWithAggregations, dataFilters, sortField, sortDirection])
+ }, [
+ headers,
+ dataWithAggregations,
+ dataFilters,
+ sortField,
+ sortDirection,
+ showOnlySelected,
+ selectedIdSet,
+ ])
// EE layers and event layers may be loading additional data
const isLoading =
diff --git a/src/components/map/ContextMenu.jsx b/src/components/map/ContextMenu.jsx
index a7a4255293..3c6f923244 100644
--- a/src/components/map/ContextMenu.jsx
+++ b/src/components/map/ContextMenu.jsx
@@ -23,6 +23,8 @@ import {
FACILITY_LAYER,
GEOJSON_URL_LAYER,
EARTH_ENGINE_LAYER,
+ EVENT_LAYER,
+ TRACKED_ENTITY_LAYER,
RENDERING_STRATEGY_SPLIT_BY_PERIOD,
} from '../../constants/layers.js'
import { getGeojsonFeatureProfile } from '../../util/geojson.js'
@@ -43,6 +45,7 @@ const ContextMenu = (props) => {
layerConfig,
coordinates,
earthEngineLayers,
+ selectedIds,
position,
offset,
closeContextMenu,
@@ -61,6 +64,9 @@ const ContextMenu = (props) => {
const isSplitView =
layerConfig?.renderingStrategy === RENDERING_STRATEGY_SPLIT_BY_PERIOD
+ const supportsProfileAndDrill =
+ layerType !== EVENT_LAYER && layerType !== TRACKED_ENTITY_LAYER
+
const left = offset[0] + position[0]
const top = offset[1] + position[1]
@@ -117,6 +123,21 @@ const ContextMenu = (props) => {
zoom: true,
})
break
+ case 'zoom_to_layer':
+ highlightFeature({
+ layerId: layerConfig.id,
+ origin: 'map',
+ zoom: true,
+ })
+ break
+ case 'zoom_to_selected':
+ highlightFeature({
+ ids: selectedIds,
+ layerId: layerConfig.id,
+ origin: 'map',
+ zoom: true,
+ })
+ break
default:
}
@@ -137,7 +158,8 @@ const ContextMenu = (props) => {
>